TFI Snippets and History

This commit is contained in:
Kaede Akino committed 2026-09-12 13:10:55 +08:00
1 parent 5ee3cded0a
commit e7105ed45d
21 files changed
+817 -237

No files matched your search

+87
View File
@@ -0,0 +1,87 @@
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
import TermindPersistence
import Testing
@Suite
@MainActor
struct SFTPHistoryTests {
@Test func memoryStoreNormalizesDeduplicatesSearchesAndScopesByHost() throws {
let firstHost = UUID()
let secondHost = UUID()
let model = SFTPHistoryModel()
model.record(
kind: .directory, path: "/srv/../srv/app", for: firstHost,
at: Date(timeIntervalSince1970: 1)
)
model.record(
kind: .file, path: "/srv/app/config.json", for: firstHost,
at: Date(timeIntervalSince1970: 2)
)
model.record(
kind: .directory, path: "/srv/app", for: firstHost,
at: Date(timeIntervalSince1970: 3)
)
model.record(
kind: .directory, path: "/var/log", for: secondHost,
at: Date(timeIntervalSince1970: 4)
)
#expect(model.entries(for: firstHost).map(\.kind) == [.directory, .file])
#expect(model.entries(for: firstHost).map(\.path) == ["/srv/app", "/srv/app/config.json"])
#expect(model.entries(for: firstHost, matching: "CONFIG").map(\.kind) == [.file])
#expect(model.entries(for: secondHost).map(\.path) == ["/var/log"])
}
@Test func memoryStoreTrimsDeletesAndClearsPerHost() throws {
let hostID = UUID()
let otherHostID = UUID()
let model = SFTPHistoryModel()
for index in 0...SFTPHistoryModel.perHostLimit {
model.record(
kind: .directory, path: "/directory-\(index)", for: hostID,
at: Date(timeIntervalSince1970: TimeInterval(index))
)
}
model.record(kind: .file, path: "/keep.txt", for: otherHostID)
let entries = model.entries(for: hostID)
#expect(entries.count == SFTPHistoryModel.perHostLimit)
#expect(entries.first?.path == "/directory-1000")
#expect(entries.last?.path == "/directory-1")
let removed = try #require(entries.first)
model.delete(removed.id)
#expect(!model.entries(for: hostID).contains { $0.id == removed.id })
model.clear(for: hostID)
#expect(model.entries(for: hostID).isEmpty)
#expect(model.entries(for: otherHostID).map(\.path) == ["/keep.txt"])
}
@Test func swiftDataRepositoryPersistsAndClearsPerHost() throws {
let container = try TermindPersistence.makeContainer(inMemory: true)
let hostID = UUID()
let otherHostID = UUID()
let model = SFTPHistoryModel(
repository: SwiftDataSFTPHistoryRepository(context: ModelContext(container))
)
model.record(kind: .directory, path: "/var/log", for: hostID)
model.record(kind: .file, path: "/tmp/a.txt", for: otherHostID)
#expect(model.storageError == nil)
let reloaded = SFTPHistoryModel(
repository: SwiftDataSFTPHistoryRepository(context: ModelContext(container))
)
#expect(reloaded.entries(for: hostID).map(\.path) == ["/var/log"])
#expect(reloaded.entries(for: otherHostID).map(\.path) == ["/tmp/a.txt"])
reloaded.clear(for: hostID)
#expect(reloaded.entries(for: hostID).isEmpty)
#expect(reloaded.entries(for: otherHostID).map(\.path) == ["/tmp/a.txt"])
}
}
@@ -3,23 +3,28 @@ import TermindDomain
import struct TermindDomain.Host
/// Incremental persistence boundary for SFTP browse history.
///
/// Preview and focused-test instances use the in-memory projection. Each app
/// supplies a persistent repository so successful directory visits and file
/// downloads remain available across sessions without entering Vault sync.
@MainActor
@Observable
final class SFTPHistoryModel {
static let perHostLimit = 1_000
public final class SFTPHistoryModel {
public static let perHostLimit = 1_000
private let repository: any SFTPHistoryRepository
private(set) var revision: UInt64 = 0
private(set) var storageError: String?
public private(set) var revision: UInt64 = 0
public private(set) var storageError: String?
init(
public init(
entries: [SFTPHistoryEntry] = [],
repository: (any SFTPHistoryRepository)? = nil
) {
self.repository = repository ?? InMemorySFTPHistoryRepository(entries: entries)
}
func entries(for hostID: Host.ID, matching query: String = "") -> [SFTPHistoryEntry] {
public func entries(for hostID: Host.ID, matching query: String = "") -> [SFTPHistoryEntry] {
let values: [SFTPHistoryEntry]
do {
values = try repository.entries(for: hostID)
@@ -32,7 +37,7 @@ final class SFTPHistoryModel {
return values.filter { $0.path.localizedCaseInsensitiveContains(query) }
}
func record(
public func record(
kind: SFTPHistoryEntry.Kind, path: String, for hostID: Host.ID,
at date: Date = Date()
) {
@@ -49,19 +54,23 @@ final class SFTPHistoryModel {
}
}
func delete(_ id: SFTPHistoryEntry.ID) {
public func delete(_ id: SFTPHistoryEntry.ID) {
do {
try repository.delete(id)
storageError = nil
revision &+= 1
} catch { storageError = error.localizedDescription }
} catch {
storageError = error.localizedDescription
}
}
func clear(for hostID: Host.ID) {
public func clear(for hostID: Host.ID) {
do {
try repository.clear(for: hostID)
storageError = nil
revision &+= 1
} catch { storageError = error.localizedDescription }
} catch {
storageError = error.localizedDescription
}
}
}
@@ -4,7 +4,7 @@ import TermindDomain
import struct TermindDomain.Host
@MainActor
protocol SFTPHistoryRepository: AnyObject {
public protocol SFTPHistoryRepository: AnyObject {
func entries(for hostID: Host.ID) throws -> [SFTPHistoryEntry]
func upsert(_ entry: SFTPHistoryEntry, limit: Int) throws
func delete(_ id: SFTPHistoryEntry.ID) throws
@@ -12,16 +12,16 @@ protocol SFTPHistoryRepository: AnyObject {
}
@MainActor
final class InMemorySFTPHistoryRepository: SFTPHistoryRepository {
public final class InMemorySFTPHistoryRepository: SFTPHistoryRepository {
private var values: [SFTPHistoryEntry]
init(entries: [SFTPHistoryEntry] = []) { values = entries }
public init(entries: [SFTPHistoryEntry] = []) { values = entries }
func entries(for hostID: Host.ID) throws -> [SFTPHistoryEntry] {
public func entries(for hostID: Host.ID) throws -> [SFTPHistoryEntry] {
values.filter { $0.hostID == hostID }.sorted { $0.visitedAt > $1.visitedAt }
}
func upsert(_ entry: SFTPHistoryEntry, limit: Int) throws {
public func upsert(_ entry: SFTPHistoryEntry, limit: Int) throws {
values.removeAll {
$0.hostID == entry.hostID && $0.kind == entry.kind && $0.path == entry.path
}
@@ -30,6 +30,11 @@ final class InMemorySFTPHistoryRepository: SFTPHistoryRepository {
values.removeAll { $0.hostID == entry.hostID && !retained.contains($0.id) }
}
func delete(_ id: SFTPHistoryEntry.ID) throws { values.removeAll { $0.id == id } }
func clear(for hostID: Host.ID) throws { values.removeAll { $0.hostID == hostID } }
public func delete(_ id: SFTPHistoryEntry.ID) throws {
values.removeAll { $0.id == id }
}
public func clear(for hostID: Host.ID) throws {
values.removeAll { $0.hostID == hostID }
}
}
@@ -1,7 +1,10 @@
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
import struct TermindDomain.Host
@Model
public final class PersistentSFTPHistoryEntry {
@Attribute(.unique) public var id: UUID = UUID()
@@ -26,3 +29,65 @@ public final class PersistentSFTPHistoryEntry {
)
}
}
@MainActor
public final class SwiftDataSFTPHistoryRepository: SFTPHistoryRepository {
private let context: ModelContext
public init(context: ModelContext) { self.context = context }
public func entries(for hostID: Host.ID) throws -> [SFTPHistoryEntry] {
try context.fetch(
FetchDescriptor<PersistentSFTPHistoryEntry>(
predicate: #Predicate { $0.hostID == hostID },
sortBy: [SortDescriptor(\.visitedAt, order: .reverse)]
)
).map(\.value)
}
public func upsert(_ entry: SFTPHistoryEntry, limit: Int) throws {
let hostID = entry.hostID
try context.commit {
let rows = try context.fetch(
FetchDescriptor<PersistentSFTPHistoryEntry>(
predicate: #Predicate { $0.hostID == hostID },
sortBy: [SortDescriptor(\.visitedAt, order: .reverse)]
))
if let row = rows.first(where: {
$0.kind == entry.kind.rawValue && $0.path == entry.path
}) {
row.visitedAt = entry.visitedAt
} else {
context.insert(PersistentSFTPHistoryEntry(entry))
}
}
try context.commit {
var overflow = FetchDescriptor<PersistentSFTPHistoryEntry>(
predicate: #Predicate { $0.hostID == hostID },
sortBy: [SortDescriptor(\.visitedAt, order: .reverse)]
)
overflow.fetchOffset = limit
for row in try context.fetch(overflow) { context.delete(row) }
}
}
public func delete(_ id: SFTPHistoryEntry.ID) throws {
try context.commit {
var descriptor = FetchDescriptor<PersistentSFTPHistoryEntry>(
predicate: #Predicate { $0.id == id }
)
descriptor.fetchLimit = 1
if let row = try context.fetch(descriptor).first { context.delete(row) }
}
}
public func clear(for hostID: Host.ID) throws {
try context.commit {
try context.delete(
model: PersistentSFTPHistoryEntry.self,
where: #Predicate { $0.hostID == hostID }
)
}
}
}
+9 -3
View File
@@ -121,8 +121,8 @@ infrastructure, persistence, or presentation responsibility.
- `RemoteFile` is a Domain value and `RemoteFileSystem` is the Application port for listing and mutating remote
entries. The libssh2-backed `SFTPClientProtocol` refines that port in Remote Infrastructure. File-kind symbols and
localized kind labels belong to Presentation. Mobile directory path, listing, and stale-request state live in
`MobileSFTPSessionModel`; `MobileWorkspace` only injects the connected file-system port and reacts to connection
loss.
`MobileSFTPSessionModel`; `MobileWorkspace` injects the connected file-system port and app-wide SFTP History facade,
and reacts to connection loss.
- Connection Journey's header, ordinary status, route graph, decision forms, and reconnect countdown live in
Presentation. Desktop and mobile retain their own log renderer, failure actions, background, and keyboard bridge.
- `RemoteSystemInfo.distributionDetection` is the only mapping from untrusted remote metadata to a persisted
@@ -162,10 +162,16 @@ commands, old protocol events, and history migrated from older versions preserve
an honest unknown outcome. Duration is never inferred from client receive time,
because SSH buffering can deliver start and completion events together.
SFTP History is device-local persisted truth shared by the desktop and mobile
apps. A directory is recorded only after its listing succeeds and survives the
current connection-generation gate. A file is recorded only after its download
completes successfully. Failed, cancelled, missing, type-changed, and stale
operations do not create or refresh History rows.
Feature views receive aggregate-facing Application Models rather than a generic
store API: `HostLibraryModel`, `CredentialLibraryModel`, `KnownHostsLibraryModel`, `SnippetLibraryModel`,
`ForwardingLibraryModel`, `TransferLibraryModel`, `AIConfigurationModel`,
`SkillLibraryModel`, `CommandHistoryModel`, and `ToolAuditModel`. Aggregates that share one persisted
`SkillLibraryModel`, `CommandHistoryModel`, `SFTPHistoryModel`, and `ToolAuditModel`. Aggregates that share one persisted
vault snapshot still expose separate intent surfaces, so a feature cannot gain
unrelated write operations merely because its records are saved together.
+9 -2
View File
@@ -48,6 +48,7 @@ final class MobileAppModel {
var error: String?
let terminalSettings: TerminalSettingsModel
let commandHistory: CommandHistoryModel
let sftpHistory: SFTPHistoryModel
let connectionHistory: ConnectionHistoryModel
let vaultSync: VaultSyncCoordinator?
let settingsSync: SettingsSyncCoordinator?
@@ -123,6 +124,8 @@ final class MobileAppModel {
terminalSettings = TerminalSettingsModel(persistence: settingsPersistence)
commandHistory = CommandHistoryModel(
repository: SwiftDataCommandHistoryRepository(context: ModelContext(container)))
sftpHistory = SFTPHistoryModel(
repository: SwiftDataSFTPHistoryRepository(context: ModelContext(container)))
connectionHistory = ConnectionHistoryModel(
repository: SwiftDataConnectionHistoryRepository(context: ModelContext(container)))
settingsSync =
@@ -157,6 +160,7 @@ final class MobileAppModel {
guard let self else { return }
for workspace in workspaces.filter({ $0.descriptor.hostID == payload.hostID }) { close(workspace.id) }
commandHistory.clear(for: payload.hostID)
sftpHistory.clear(for: payload.hostID)
library.projectHostDeletion(payload.hostID)
})
outbox.register(
@@ -186,7 +190,8 @@ final class MobileAppModel {
workspaces = descriptors.filter { library.host($0.hostID) != nil }.map {
MobileWorkspace(
descriptor: $0, hosts: library, transfers: transfers, terminalSettings: terminalSettings,
commandHistory: commandHistory, connectionServices: workspaceConnectionServices,
commandHistory: commandHistory, sftpHistory: sftpHistory,
connectionServices: workspaceConnectionServices,
connectsWhenOpened: true)
}
workspaceActivitySessions = workspaces.map(\.activitySession)
@@ -350,6 +355,7 @@ final class MobileAppModel {
}
if removed {
commandHistory.clear(for: id)
sftpHistory.clear(for: id)
metrics.remove(id)
}
}
@@ -402,7 +408,8 @@ final class MobileAppModel {
return MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: kind),
hosts: library, transfers: transfers, terminalSettings: terminalSettings,
commandHistory: commandHistory, connectionServices: workspaceConnectionServices,
commandHistory: commandHistory, sftpHistory: sftpHistory,
connectionServices: workspaceConnectionServices,
initialCommand: initialCommand,
recordsInitialCommandInHistory: recordsInitialCommandInHistory,
initialSFTPPath: initialSFTPPath)
@@ -3,6 +3,8 @@ import Observation
import TermindApplication
import TermindDomain
import struct TermindDomain.Host
@MainActor
@Observable
final class MobileSFTPSessionModel {
@@ -22,11 +24,18 @@ final class MobileSFTPSessionModel {
}
@ObservationIgnored private var fileSystem: (any RemoteFileSystem)?
@ObservationIgnored private let hostID: Host.ID?
@ObservationIgnored private let history: SFTPHistoryModel?
@ObservationIgnored private var connectionGeneration: UUID?
@ObservationIgnored private var directoryRequest = UUID()
@ObservationIgnored private var handlesConnectionError: ((any Error) -> Bool)?
@ObservationIgnored private var didChangePath: (() -> Void)?
init(hostID: Host.ID? = nil, history: SFTPHistoryModel? = nil) {
self.hostID = hostID
self.history = history
}
func attach(
fileSystem: any RemoteFileSystem,
path: String,
@@ -47,6 +56,7 @@ final class MobileSFTPSessionModel {
self.path = path
pathText = path
files = entries
recordDirectory(path)
}
func detach() {
@@ -77,6 +87,7 @@ final class MobileSFTPSessionModel {
path = destination
pathText = destination
files = entries
recordDirectory(destination)
didChangePath?()
} catch {
guard self.connectionGeneration == connectionGeneration, directoryRequest == request else { return }
@@ -99,6 +110,7 @@ final class MobileSFTPSessionModel {
path = destination
pathText = destination
files = entries
recordDirectory(destination)
didChangePath?()
} catch {
guard self.connectionGeneration == connectionGeneration, directoryRequest == request else { return }
@@ -121,6 +133,7 @@ final class MobileSFTPSessionModel {
path = destination
pathText = destination
files = entries
recordDirectory(destination)
didChangePath?()
} catch {
guard self.connectionGeneration == connectionGeneration, directoryRequest == request else { return }
@@ -139,6 +152,7 @@ final class MobileSFTPSessionModel {
path == requestedPath
else { return }
files = entries
recordDirectory(requestedPath)
} catch {
guard self.connectionGeneration == connectionGeneration, directoryRequest == request else { return }
record(error)
@@ -190,4 +204,9 @@ final class MobileSFTPSessionModel {
operationError = error.localizedDescription
}
}
private func recordDirectory(_ path: String) {
guard let hostID else { return }
history?.record(kind: .directory, path: path, for: hostID)
}
}
@@ -0,0 +1,118 @@
import SwiftUI
import TermindDomain
import UIKit
struct MobileSFTPHistoryView: View {
let workspace: MobileWorkspace
let open: (SFTPHistoryEntry) -> Void
@Environment(\.dismiss) private var dismiss
@State private var query = ""
@State private var clearing = false
private var entries: [SFTPHistoryEntry] {
_ = workspace.sftpHistory.revision
return workspace.sftpHistory.entries(for: workspace.descriptor.hostID, matching: query)
}
var body: some View {
NavigationStack {
Group {
if entries.isEmpty {
if query.isEmpty {
ContentUnavailableView(
"No History", systemImage: "clock.arrow.circlepath",
description: Text("Folders you visit and files you open appear here.")
)
} else {
ContentUnavailableView.search(text: query)
}
} else {
List(entries) { entry in
Button {
reopen(entry)
} label: {
historyRow(entry)
}
.buttonStyle(.plain)
.contextMenu {
Button("Open", systemImage: "arrow.up.forward.app") {
reopen(entry)
}
.disabled(!workspace.connected)
Button("Delete", systemImage: "trash", role: .destructive) {
workspace.sftpHistory.delete(entry.id)
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button("Delete", systemImage: "trash", role: .destructive) {
workspace.sftpHistory.delete(entry.id)
}
}
}
.listStyle(.insetGrouped)
}
}
.background(Color(uiColor: .systemGroupedBackground))
.safeAreaInset(edge: .bottom) {
if let error = workspace.sftpHistory.storageError {
Text(error)
.font(.caption)
.foregroundStyle(.red)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
.padding(.vertical, 8)
.background(.bar)
}
}
.searchable(text: $query, prompt: "Search History")
.navigationTitle("History")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done", systemImage: "xmark") { dismiss() }.labelStyle(.iconOnly)
}
ToolbarItem(placement: .primaryAction) {
Button("Clear", systemImage: "trash", role: .destructive) { clearing = true }
.labelStyle(.iconOnly)
.disabled(entries.isEmpty)
}
}
.confirmationDialog(
"Clear File History for this Host?", isPresented: $clearing,
titleVisibility: .visible
) {
Button("Clear History", role: .destructive) {
workspace.sftpHistory.clear(for: workspace.descriptor.hostID)
}
}
}
.mobileSheetPresentation(compact: true)
}
private func historyRow(_ entry: SFTPHistoryEntry) -> some View {
Label {
VStack(alignment: .leading, spacing: 3) {
Text(entry.name)
.font(.body)
.lineLimit(1)
Text(entry.path)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
.frame(maxWidth: .infinity, alignment: .leading)
} icon: {
Image(systemName: entry.kind.symbol)
.foregroundStyle(entry.kind == .directory ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary))
}
.contentShape(.rect)
.accessibilityHint(entry.kind == .directory ? Text("Open") : Text("Download"))
}
private func reopen(_ entry: SFTPHistoryEntry) {
guard workspace.connected else { return }
dismiss()
open(entry)
}
}
@@ -24,15 +24,30 @@ struct MobileCommandHistoryView: View {
ContentUnavailableView.search(text: query)
}
} else {
MobileNativeList(
items: entries,
estimatedRowHeight: 68,
refreshID: workspace.connected,
onSelect: insert,
onDelete: { workspace.commandHistory.delete($0.id) }
) { entry in
historyRow(entry)
List(entries) { entry in
Button {
insert(entry)
} label: {
historyRow(entry)
}
.buttonStyle(.plain)
.contextMenu {
Button("Copy", systemImage: "doc.on.doc") {
UIPasteboard.general.string = entry.command
}
Button("Insert", systemImage: "text.insert") { insert(entry) }
.disabled(!workspace.connected)
Button("Delete", systemImage: "trash", role: .destructive) {
workspace.commandHistory.delete(entry.id)
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button("Delete", systemImage: "trash", role: .destructive) {
workspace.commandHistory.delete(entry.id)
}
}
}
.listStyle(.insetGrouped)
}
}
.background(Color(uiColor: .systemGroupedBackground))
@@ -68,11 +83,11 @@ struct MobileCommandHistoryView: View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 3) {
Text(entry.command)
.font(.caption.monospaced())
.font(.body.monospaced())
.lineLimit(2)
.textSelection(.enabled)
Text(MobileTerminalHistoryPresentation.subtitle(for: entry, locale: locale))
.font(.caption2)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
@@ -80,18 +95,7 @@ struct MobileCommandHistoryView: View {
MobileTerminalRiskIndicator(assessment: CommandRiskClassifier.assess(entry.command))
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.contentShape(.rect)
.contextMenu {
Button("Copy", systemImage: "doc.on.doc") { UIPasteboard.general.string = entry.command }
Button("Insert", systemImage: "text.insert") { insert(entry) }
.disabled(!workspace.connected)
Button("Delete", systemImage: "trash", role: .destructive) {
workspace.commandHistory.delete(entry.id)
}
}
.overlay(alignment: .bottom) { Divider() }
}
private func insert(_ entry: CommandHistoryEntry) {
@@ -21,14 +21,22 @@ struct MobileTerminalSnippetsView: View {
ContentUnavailableView.search(text: query)
}
} else {
MobileNativeList(
items: snippets,
estimatedRowHeight: 60,
refreshID: workspace.connected,
onSelect: insert
) { snippet in
snippetRow(snippet)
List(snippets) { snippet in
Button {
insert(snippet)
} label: {
snippetRow(snippet)
}
.buttonStyle(.plain)
.contextMenu {
Button("Copy", systemImage: "doc.on.doc") {
UIPasteboard.general.string = snippet.body
}
Button("Insert", systemImage: "text.insert") { insert(snippet) }
.disabled(!workspace.connected)
}
}
.listStyle(.insetGrouped)
}
}
.background(Color(uiColor: .systemGroupedBackground))
@@ -47,7 +55,7 @@ struct MobileTerminalSnippetsView: View {
private func snippetRow(_ snippet: Snippet) -> some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 5) {
Text(snippet.title).font(.body.weight(.medium)).lineLimit(1)
Text(snippet.title).font(.body).lineLimit(1)
Text(snippet.body)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
@@ -58,15 +66,7 @@ struct MobileTerminalSnippetsView: View {
MobileTerminalRiskIndicator(assessment: CommandRiskClassifier.assess(snippet.body))
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.contentShape(.rect)
.contextMenu {
Button("Copy", systemImage: "doc.on.doc") { UIPasteboard.general.string = snippet.body }
Button("Insert", systemImage: "text.insert") { insert(snippet) }
.disabled(!workspace.connected)
}
.overlay(alignment: .bottom) { Divider() }
}
private func insert(_ snippet: Snippet) {
+39 -5
View File
@@ -68,7 +68,7 @@ final class MobileWorkspace: Identifiable {
set { connection.temporaryHostKeys = newValue }
}
private var submissions: [UUID: SSHAuthenticationSubmission] = [:]
let sftpSession = MobileSFTPSessionModel()
let sftpSession: MobileSFTPSessionModel
let transfers: SFTPTransferCoordinator?
private var transferConfiguration: SSHConnectionConfiguration?
private(set) var uploadNotice: WorkspaceUploadNotice?
@@ -78,6 +78,7 @@ final class MobileWorkspace: Identifiable {
private var uploadNoticeTask: Task<Void, Never>?
let terminalSettings: TerminalSettingsModel
let commandHistory: CommandHistoryModel
let sftpHistory: SFTPHistoryModel
var terminalTextSelection: MobileTerminalTextSelection?
@ObservationIgnored lazy var find = TerminalFindController(
perform: { [weak self] in _ = self?.terminal.performBindingAction($0) },
@@ -173,6 +174,7 @@ final class MobileWorkspace: Identifiable {
transfers: SFTPTransferCoordinator? = nil,
terminalSettings: TerminalSettingsModel = TerminalSettingsModel(),
commandHistory: CommandHistoryModel = CommandHistoryModel(),
sftpHistory: SFTPHistoryModel = SFTPHistoryModel(),
connectionServices: MobileWorkspaceConnectionServices = .live,
reconnectController: SessionReconnectController = SessionReconnectController(),
initialCommand: String? = nil,
@@ -182,6 +184,8 @@ final class MobileWorkspace: Identifiable {
) {
self.terminalSettings = terminalSettings
self.commandHistory = commandHistory
self.sftpHistory = sftpHistory
sftpSession = MobileSFTPSessionModel(hostID: descriptor.hostID, history: sftpHistory)
self.transfers = transfers
self.descriptor = descriptor
self.hosts = hosts
@@ -1102,8 +1106,11 @@ final class MobileWorkspace: Identifiable {
}
}
func download(_ file: RemoteFile) {
guard let transfers, let configuration = transferConfiguration, connected, !file.isDirectory else { return }
@discardableResult
func download(_ file: RemoteFile) -> Bool {
guard let transfers, let configuration = transferConfiguration, connected, !file.isDirectory else {
return false
}
do {
guard RemoteFileName.isValid(file.name) else { throw CocoaError(.fileWriteInvalidFileName) }
let directory = FileManager.default.temporaryDirectory.appending(
@@ -1114,8 +1121,35 @@ final class MobileWorkspace: Identifiable {
transfers.enqueue(
direction: .download, batchID: UUID(), localURL: directory.appending(path: file.name),
remotePath: file.path, byteCount: file.size, hostID: descriptor.hostID,
configuration: configuration, scopedRoot: directory, overwrite: false)
} catch { operationError = error.localizedDescription }
configuration: configuration, scopedRoot: directory, overwrite: false
) { [sftpHistory, hostID = descriptor.hostID, path = file.path] state in
guard state == .completed else { return }
sftpHistory.record(kind: .file, path: path, for: hostID)
}
return true
} catch {
operationError = error.localizedDescription
return false
}
}
func reopenHistoryFile(at path: String) async -> Bool {
guard let sftp, connected else {
operationError = String(localized: "The SFTP connection is not available.")
return false
}
do {
guard let file = try await sftp.attributes(at: path), !file.isDirectory else {
operationError = String(localized: "The remote file is no longer available.")
return false
}
return download(file)
} catch {
if !handleSFTPConnectionError(error, connection: generation) {
operationError = error.localizedDescription
}
return false
}
}
private func handleSFTPConnectionError(_ error: any Error, connection: UUID) -> Bool {
@@ -12,6 +12,7 @@ struct MobileWorkspaceView: View {
private enum TerminalSheet: String, Identifiable {
case snippets
case history
case sftpHistory
var id: String { rawValue }
}
@@ -135,6 +136,7 @@ struct MobileWorkspaceView: View {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button("Transfers", systemImage: "arrow.up.arrow.down") { showingTransfers = true }
Button("History", systemImage: "clock") { terminalSheet = .sftpHistory }
Divider()
Button("Parent Directory", systemImage: "arrow.up") {
Task {
@@ -168,6 +170,8 @@ struct MobileWorkspaceView: View {
MobileTerminalSnippetsView(library: library, workspace: workspace)
case .history:
MobileCommandHistoryView(workspace: workspace)
case .sftpHistory:
MobileSFTPHistoryView(workspace: workspace, open: reopenHistory)
}
}
.sheet(
@@ -306,6 +310,19 @@ struct MobileWorkspaceView: View {
}
}
private func reopenHistory(_ entry: SFTPHistoryEntry) {
switch entry.kind {
case .directory:
Task { await workspace.sftpSession.openDirectory(entry.path) }
case .file:
Task {
if await workspace.reopenHistoryFile(at: entry.path) {
showingTransfers = true
}
}
}
}
private var uploadConflictTitle: String {
workspace.uploadConflictCount == 1
? String(localized: "1 item already exists")
+17
View File
@@ -27,6 +27,23 @@ struct MobileAppTests {
try repository.upsertHost(host)
#expect(try app.hosts.snapshot().value.hosts == [host])
}
@Test func historyModelsUseTheSharedSwiftDataContainer() throws {
let app = try MobileAppModel(inMemory: true)
let hostID = UUID()
app.commandHistory.record("echo persisted", for: hostID)
app.sftpHistory.record(kind: .directory, path: "/srv/app", for: hostID)
let commands = CommandHistoryModel(
repository: SwiftDataCommandHistoryRepository(context: ModelContext(app.container))
)
let files = SFTPHistoryModel(
repository: SwiftDataSFTPHistoryRepository(context: ModelContext(app.container))
)
#expect(commands.entries(for: hostID).map(\.command) == ["echo persisted"])
#expect(files.entries(for: hostID).map(\.path) == ["/srv/app"])
}
}
@Suite @MainActor
@@ -524,6 +524,87 @@ struct MobileUploadBatchTests {
)
}
@Test func successfulFileDownloadsRefreshHistoryWhileInvalidAndFailedFilesDoNot() async throws {
let identity = Identity(name: "Deploy", username: "deploy")
var host = Host(name: "Example", host: "example.invalid")
host.identityID = identity.id
let library = HostLibraryModel(
hosts: [host],
identities: [identity],
knownHosts: [
KnownHostEntry(
hostname: host.host,
port: host.port,
algorithm: .ed25519,
fingerprint: "SHA256:test"
)
]
)
try library.upsert(identity, passwordUpdate: .replace("test-only"))
let report = RemoteFile(
path: "/srv/report.txt", name: "report.txt", kind: .file, size: 4,
permissions: 0o644, ownerUID: 501, ownerGID: 20)
let broken = RemoteFile(
path: "/srv/broken.txt", name: "broken.txt", kind: .file, size: 4,
permissions: 0o644, ownerUID: 501, ownerGID: 20)
let changedDirectory = RemoteFile(
path: "/srv/changed", name: "changed", kind: .directory, size: 0,
permissions: 0o755, ownerUID: 501, ownerGID: 20)
let sessionClient = MobileUploadSFTPClient(
attributes: [
report.path: report,
broken.path: broken,
changedDirectory.path: changedDirectory,
])
let transferClient = MobileUploadSFTPClient(failedDownloadPaths: [broken.path])
let transfers = SFTPTransferCoordinator(store: library) { transferClient }
let services = MobileWorkspaceConnectionServices(
discoverHostKey: { _, name, port, _, _ in
SSHHostKeyObservation(
hostname: name,
port: port,
algorithm: .ed25519,
fingerprint: "SHA256:test"
)
},
makeSSHSession: { SSHSessionActor(output: $0, exit: $1, shellIntegration: $2) },
connectSSH: { _, _, _, _, _, _ in },
connectSFTP: { _, _, _ in sessionClient },
writeSSH: { _, _ in }
)
let history = SFTPHistoryModel()
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .sftp),
hosts: library,
transfers: transfers,
sftpHistory: history,
connectionServices: services
)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
#expect(await workspace.reopenHistoryFile(at: report.path))
#expect(
await eventually {
history.entries(for: host.id).contains { $0.kind == .file && $0.path == report.path }
})
#expect(await workspace.reopenHistoryFile(at: broken.path))
#expect(
await eventually {
guard let state = transfers.sftpTransfers.first(where: { $0.remotePath == broken.path })?.state
else { return false }
if case .failed = state { return true }
return false
})
#expect(!history.entries(for: host.id).contains { $0.path == broken.path })
#expect(!(await workspace.reopenHistoryFile(at: "/srv/missing.txt")))
#expect(!(await workspace.reopenHistoryFile(at: changedDirectory.path)))
#expect(!history.entries(for: host.id).contains { $0.path == changedDirectory.path })
}
@Test func sharedUploadPlannerDoesNotFollowASymlinkOutsideTheSelectedFolder() async throws {
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
let upload = root.appending(path: "upload", directoryHint: .isDirectory)
@@ -607,12 +688,22 @@ struct MobileUploadBatchTests {
private actor MobileUploadSFTPClient: SFTPClientProtocol {
private(set) var createdDirectories: [String] = []
private(set) var disconnectCount = 0
private let attributes: [String: RemoteFile]
private let failedDownloadPaths: Set<String>
init(
attributes: [String: RemoteFile] = [:],
failedDownloadPaths: Set<String> = []
) {
self.attributes = attributes
self.failedDownloadPaths = failedDownloadPaths
}
func connect(using configuration: SSHConnectionConfiguration, progress: SSHRouteProgressHandler?) async throws {}
func disconnect() async { disconnectCount += 1 }
func homeDirectory() async throws -> String { "/home/test" }
func listDirectory(at path: String) async throws -> [RemoteFile] { [] }
func attributes(at path: String) async throws -> RemoteFile? { nil }
func attributes(at path: String) async throws -> RemoteFile? { attributes[path] }
func setPermissions(at path: String, to permissions: UInt32) async throws {}
func createDirectory(at path: String) async throws { createdDirectories.append(path) }
func rename(from source: String, to destination: String, overwrite: Bool) async throws {}
@@ -631,5 +722,9 @@ private actor MobileUploadSFTPClient: SFTPClientProtocol {
overwrite: Bool,
cancellation: TransferCancellationToken,
progress: @escaping @Sendable (SFTPProgress) -> Void
) async throws {}
) async throws {
if failedDownloadPaths.contains(remotePath) {
throw SFTPError.operation("Download failed")
}
}
}
+83
View File
@@ -0,0 +1,83 @@
import SwiftUI
import TermindApplication
import TermindDomain
import Testing
import UIKit
@testable import TermindIOS
@Suite(.serialized)
@MainActor
struct MobileSFTPHistoryTests {
@Test func historyUsesNativeListContextActionsAndFullSwipeDeletion() throws {
let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent()
let history = try String(
contentsOf: root.appending(path: "TFI/Features/SFTP/MobileSFTPHistoryView.swift"),
encoding: .utf8
)
let workspace = try String(
contentsOf: root.appending(path: "TFI/Features/Workspaces/MobileWorkspaceView.swift"),
encoding: .utf8
)
#expect(history.contains("List(entries)"))
#expect(history.contains(".listStyle(.insetGrouped)"))
#expect(history.contains(".contextMenu"))
#expect(history.contains(".swipeActions(edge: .trailing, allowsFullSwipe: true)"))
#expect(history.contains("workspace.sftpHistory.delete(entry.id)"))
#expect(history.contains("workspace.sftpHistory.clear(for: workspace.descriptor.hostID)"))
#expect(workspace.contains("Button(\"History\", systemImage: \"clock\")"))
#expect(workspace.contains("MobileSFTPHistoryView(workspace: workspace, open: reopenHistory)"))
}
@Test func deletingAHistoryEntryUpdatesThePresentedList() async throws {
let host = TermindDomain.Host(name: "Test", host: "example.invalid")
let library = HostLibraryModel(hosts: [host])
let history = SFTPHistoryModel()
history.record(kind: .directory, path: "/srv", for: host.id)
history.record(kind: .file, path: "/srv/report.txt", for: host.id)
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .sftp),
hosts: library,
sftpHistory: history
)
let controller = UIHostingController(
rootView: MobileSFTPHistoryView(workspace: workspace, open: { _ in })
)
let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = controller
window.makeKeyAndVisible()
defer {
window.isHidden = true
window.rootViewController = nil
}
controller.view.frame = window.bounds
controller.view.setNeedsLayout()
controller.view.layoutIfNeeded()
try await Task.sleep(for: .milliseconds(300))
#expect(visibleCells(in: controller.view).count == 2)
let newest = try #require(history.entries(for: host.id).first)
history.delete(newest.id)
try await Task.sleep(for: .milliseconds(300))
#expect(visibleCells(in: controller.view).count == 1)
}
private func visibleCells(in root: UIView) -> [UIView] {
let collectionCells = descendants(of: UICollectionViewCell.self, in: root)
let tableCells = descendants(of: UITableViewCell.self, in: root)
return (collectionCells.map { $0 as UIView } + tableCells.map { $0 as UIView })
.filter { !$0.isHidden && $0.window != nil }
}
private func descendants<T: UIView>(of type: T.Type, in root: UIView) -> [T] {
var result: [T] = []
var pending = [root]
while let view = pending.popLast() {
if let match = view as? T { result.append(match) }
pending.append(contentsOf: view.subviews)
}
return result
}
}
+81
View File
@@ -4,6 +4,8 @@ import TermindDomain
import Testing
import UIKit
import struct TermindDomain.Host
@testable import TermindIOS
@Suite @MainActor
@@ -78,6 +80,57 @@ struct MobileSFTPSessionTests {
#expect(session.visibleFiles == [folder, notes, report])
}
@Test func successfulDirectoriesRefreshPersistentHistoryAndFailuresDoNot() async throws {
let hostID = UUID()
let history = SFTPHistoryModel()
let fileSystem = MobileBrowserFileSystem(
listings: [
"/home": [],
"/srv": [],
])
let session = MobileSFTPSessionModel(hostID: hostID, history: history)
try await attach(session, to: fileSystem, path: "/home/")
await session.openDirectory("/srv")
await session.reload()
#expect(history.entries(for: hostID).map(\.path) == ["/srv", "/home"])
await session.openDirectory("/missing")
#expect(history.entries(for: hostID).map(\.path) == ["/srv", "/home"])
}
@Test func staleDirectoryResponseDoesNotCreateHistory() async throws {
let hostID = UUID()
let history = SFTPHistoryModel()
let staleFileSystem = MobileDeferredFileSystem()
let currentFileSystem = MobileBrowserFileSystem(listings: ["/current": []])
let session = MobileSFTPSessionModel(hostID: hostID, history: history)
let staleGeneration = UUID()
let staleAttach = Task {
try? await session.attach(
fileSystem: staleFileSystem,
path: "/stale",
connectionGeneration: staleGeneration,
handlesConnectionError: { _ in false },
didChangePath: {}
)
}
await staleFileSystem.waitUntilRequested()
try await session.attach(
fileSystem: currentFileSystem,
path: "/current",
connectionGeneration: UUID(),
handlesConnectionError: { _ in false },
didChangePath: {}
)
await staleFileSystem.succeed()
await staleAttach.value
#expect(session.path == "/current")
#expect(history.entries(for: hostID).map(\.path) == ["/current"])
}
@Test func interactivePopRequiresAParentNavigationDestination() {
let root = UIViewController()
let controller = MobileInteractivePopGesture.Controller()
@@ -139,6 +192,34 @@ private actor MobileBrowserFileSystem: RemoteFileSystem {
func removeDirectory(at _: String) async throws {}
}
private actor MobileDeferredFileSystem: RemoteFileSystem {
private var requested = false
private var continuation: CheckedContinuation<[RemoteFile], any Error>?
func waitUntilRequested() async {
while !requested { await Task.yield() }
}
func succeed() {
continuation?.resume(returning: [])
continuation = nil
}
func listDirectory(at _: String) async throws -> [RemoteFile] {
requested = true
return try await withCheckedThrowingContinuation { continuation = $0 }
}
func attributes(at _: String) async throws -> RemoteFile? { nil }
func readFile(at _: String, maximumBytes _: Int) async throws -> Data { Data() }
func writeFile(_: Data, at _: String, overwrite _: Bool) async throws {}
func setPermissions(at _: String, to _: UInt32) async throws {}
func createDirectory(at _: String) async throws {}
func rename(from _: String, to _: String, overwrite _: Bool) async throws {}
func removeFile(at _: String) async throws {}
func removeDirectory(at _: String) async throws {}
}
private enum MobileBrowserFileSystemError: LocalizedError {
case missing(String)
+97 -30
View File
@@ -1,4 +1,5 @@
import SwiftUI
import TermindApplication
import TermindDomain
import Testing
import UIKit
@@ -7,7 +8,7 @@ import UIKit
@Suite(.serialized) @MainActor
struct MobileTerminalListTests {
@Test func snippetsAndHistoryMountListViewKitRows() async throws {
@Test func snippetsAndHistoryMountNativeSwiftUILists() async throws {
let app = try MobileAppModel(inMemory: true)
let host = TermindDomain.Host(name: "Test", host: "example.invalid")
app.library.upsert(host)
@@ -19,35 +20,75 @@ struct MobileTerminalListTests {
commandHistory: app.commandHistory)
try await withHostedView(MobileTerminalSnippetsView(library: app.library, workspace: workspace)) { root in
let rows = descendants(of: MobileHostingListRow.self, in: root.view).filter { !$0.isHidden }
#expect(rows.count == 1)
#expect(rows.first?.accessibilityCustomActions == nil)
#expect(rows.first?.backgroundColor == .secondarySystemGroupedBackground)
let list = descendants(of: UIScrollView.self, in: root.view).first {
String(reflecting: type(of: $0)).contains("ListViewKit.ListView")
}
#expect(list?.backgroundColor == .systemGroupedBackground)
expectNativeList(in: root.view)
}
try await withHostedView(MobileCommandHistoryView(workspace: workspace)) { root in
let row = try #require(
descendants(of: MobileHostingListRow.self, in: root.view).first { !$0.isHidden })
#expect(row.accessibilityCustomActions?.map(\.name).contains(String(localized: "Delete")) == true)
#expect(row.backgroundColor == .secondarySystemGroupedBackground)
let deleteButton = try #require(
descendants(of: UIButton.self, in: row).first {
$0.accessibilityLabel == String(localized: "Delete")
})
row.layoutIfNeeded()
#expect(deleteButton.configuration == nil)
#expect(deleteButton.backgroundColor == .systemRed)
#expect(deleteButton.tintColor == .white)
#expect(deleteButton.currentImage != nil)
#expect(deleteButton.frame.width == MobileHostingListRow.deleteWidth)
#expect(deleteButton.frame.height == row.bounds.height)
#expect(deleteButton.frame.maxX == row.bounds.maxX)
row.prepareForReuse()
#expect(row.accessibilityCustomActions == nil)
expectNativeList(in: root.view)
}
}
@Test func deletingAnEndpointUpdatesTheNativeHistoryList() async throws {
let app = try MobileAppModel(inMemory: true)
let host = TermindDomain.Host(name: "Test", host: "example.invalid")
app.library.upsert(host)
app.commandHistory.record("pwd", for: host.id)
app.commandHistory.record("whoami", for: host.id)
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: app.library,
commandHistory: app.commandHistory)
try await withHostedView(MobileCommandHistoryView(workspace: workspace)) { root in
#expect(nativeVisibleCells(in: root.view).count == 2)
let newest = try #require(app.commandHistory.entries(for: host.id).first)
app.commandHistory.delete(newest.id)
try await Task.sleep(for: .milliseconds(300))
#expect(nativeVisibleCells(in: root.view).count == 1)
}
}
@Test func thousandRowHistoryKeepsOnlyTheNativeViewportMounted() async throws {
let app = try MobileAppModel(inMemory: true)
let host = TermindDomain.Host(name: "Test", host: "example.invalid")
app.library.upsert(host)
let history = CommandHistoryModel(
entries: (0..<CommandHistoryModel.perHostLimit).map { index in
CommandHistoryEntry(
hostID: host.id, command: "printf '%04d' \(index)",
executedAt: Date(timeIntervalSince1970: TimeInterval(index)))
})
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: app.library,
commandHistory: history)
try await withHostedView(MobileCommandHistoryView(workspace: workspace)) { root in
#expect(history.entries(for: host.id).count == CommandHistoryModel.perHostLimit)
let cells = nativeVisibleCells(in: root.view)
#expect(!cells.isEmpty)
#expect(cells.count < 50)
}
}
@Test func nativeHistoryRemeasuresForAccessibilityDynamicType() async throws {
let app = try MobileAppModel(inMemory: true)
let host = TermindDomain.Host(name: "Test", host: "example.invalid")
app.library.upsert(host)
app.commandHistory.record("echo a command that needs two lines in the compact sheet", for: host.id)
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: app.library,
commandHistory: app.commandHistory)
try await withHostedView(
MobileCommandHistoryView(workspace: workspace)
.environment(\.dynamicTypeSize, .accessibility3)
) { root in
let cell = try #require(nativeVisibleCells(in: root.view).first)
#expect(cell.bounds.height > 100)
}
}
@@ -60,7 +101,7 @@ struct MobileTerminalListTests {
#expect(MobileTerminalRiskIndicator.accessibilityIdentifier(for: .destructive) == "TerminalRisk.destructive")
}
@Test func terminalListsUseCompactSidebarContentAndSingleClickInsertion() throws {
@Test func terminalListsUseNativeActionsAndCompactContent() throws {
let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent()
let history = try String(
contentsOf: root.appending(path: "TFI/Features/Terminal/MobileCommandHistoryView.swift"),
@@ -69,16 +110,26 @@ struct MobileTerminalListTests {
contentsOf: root.appending(path: "TFI/Features/Terminal/MobileTerminalSnippetsView.swift"),
encoding: .utf8)
#expect(history.contains("onSelect: insert"))
#expect(history.contains("List(entries)"))
#expect(history.contains(".listStyle(.insetGrouped)"))
#expect(history.contains(".contextMenu"))
#expect(history.contains(".swipeActions(edge: .trailing, allowsFullSwipe: true)"))
#expect(history.contains(".disabled(!workspace.connected)"))
#expect(!history.contains("MobileNativeList("))
#expect(history.contains(".lineLimit(2)"))
#expect(!history.contains("Exit Status"))
#expect(history.contains("MobileTerminalRiskIndicator"))
let bodyStart = try #require(snippets.range(of: "Text(snippet.body)"))
let bodyEnd = try #require(snippets[bodyStart.upperBound...].range(of: "Spacer(minLength: 8)"))
let bodyPreview = snippets[bodyStart.lowerBound..<bodyEnd.lowerBound]
#expect(snippets.contains("List(snippets)"))
#expect(snippets.contains(".listStyle(.insetGrouped)"))
#expect(snippets.contains(".contextMenu"))
#expect(snippets.contains(".disabled(!workspace.connected)"))
#expect(!snippets.contains("MobileNativeList("))
#expect(bodyPreview.contains(".lineLimit(1)"))
#expect(bodyPreview.contains(".truncationMode(.tail)"))
#expect(snippets.contains("estimatedRowHeight: 60"))
#expect(snippets.contains("MobileTerminalRiskIndicator"))
}
@@ -101,6 +152,22 @@ struct MobileTerminalListTests {
try await check(controller)
}
private func expectNativeList(in root: UIView) {
let legacyList = descendants(of: UIScrollView.self, in: root).first {
String(reflecting: type(of: $0)).contains("ListViewKit.ListView")
}
#expect(legacyList == nil)
#expect(descendants(of: MobileHostingListRow.self, in: root).isEmpty)
#expect(!nativeVisibleCells(in: root).isEmpty)
}
private func nativeVisibleCells(in root: UIView) -> [UIView] {
let collectionCells = descendants(of: UICollectionViewCell.self, in: root)
let tableCells = descendants(of: UITableViewCell.self, in: root)
return (collectionCells.map { $0 as UIView } + tableCells.map { $0 as UIView })
.filter { !$0.isHidden && $0.window != nil }
}
private func descendants<T: UIView>(of type: T.Type, in root: UIView) -> [T] {
var result: [T] = []
var pending = [root]
+3 -1
View File
@@ -92,7 +92,9 @@ struct TermindApp: App {
skills: SkillLibraryModel(persistence: SyncedSkillLibraryPersistence(settingsPersistence)),
terminalSettings: TerminalSettingsModel(persistence: settingsPersistence),
commandHistory: CommandHistoryModel(modelContext: container.mainContext),
sftpHistory: SFTPHistoryModel(modelContext: container.mainContext),
sftpHistory: SFTPHistoryModel(
repository: SwiftDataSFTPHistoryRepository(context: container.mainContext)
),
connectionHistory: ConnectionHistoryModel(
modelContext: container.mainContext
),
@@ -1,73 +0,0 @@
import Foundation
import SwiftData
import TermindDomain
import TermindPersistence
import struct TermindDomain.Host
@MainActor
final class SwiftDataSFTPHistoryRepository: SFTPHistoryRepository {
private let context: ModelContext
init(context: ModelContext) { self.context = context }
func entries(for hostID: Host.ID) throws -> [SFTPHistoryEntry] {
try context.fetch(
FetchDescriptor<PersistentSFTPHistoryEntry>(
predicate: #Predicate { $0.hostID == hostID },
sortBy: [SortDescriptor(\.visitedAt, order: .reverse)]
)
).map(\.value)
}
func upsert(_ entry: SFTPHistoryEntry, limit: Int) throws {
let hostID = entry.hostID
try context.commit {
let rows = try context.fetch(
FetchDescriptor<PersistentSFTPHistoryEntry>(
predicate: #Predicate { $0.hostID == hostID },
sortBy: [SortDescriptor(\.visitedAt, order: .reverse)]
))
if let row = rows.first(where: {
$0.kind == entry.kind.rawValue && $0.path == entry.path
}) {
row.visitedAt = entry.visitedAt
} else {
context.insert(PersistentSFTPHistoryEntry(entry))
}
}
try context.commit {
var overflow = FetchDescriptor<PersistentSFTPHistoryEntry>(
predicate: #Predicate { $0.hostID == hostID },
sortBy: [SortDescriptor(\.visitedAt, order: .reverse)]
)
overflow.fetchOffset = limit
for row in try context.fetch(overflow) { context.delete(row) }
}
}
func delete(_ id: SFTPHistoryEntry.ID) throws {
try context.commit {
var descriptor = FetchDescriptor<PersistentSFTPHistoryEntry>(
predicate: #Predicate { $0.id == id }
)
descriptor.fetchLimit = 1
if let row = try context.fetch(descriptor).first { context.delete(row) }
}
}
func clear(for hostID: Host.ID) throws {
try context.commit {
try context.delete(
model: PersistentSFTPHistoryEntry.self,
where: #Predicate { $0.hostID == hostID }
)
}
}
}
extension SFTPHistoryModel {
convenience init(modelContext: ModelContext) {
self.init(repository: SwiftDataSFTPHistoryRepository(context: modelContext))
}
}
+3 -2
View File
@@ -408,8 +408,9 @@ struct ArchitectureBoundaryTests {
)
#expect(
mobileConsumers == [
"TFI/Features/Terminal/MobileCommandHistoryView.swift",
"TFI/Features/Terminal/MobileTerminalSnippetsView.swift",
"TFI/Features/AI/MobileAssistantConversation.swift",
"TFI/Features/Assistant/MobileAssistantView.swift",
"TFI/Features/SFTP/MobileSFTPBrowser.swift",
])
}
-64
View File
@@ -1,64 +0,0 @@
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindPersistence
import TermindPresentation
import TermindRemoteInfrastructure
import Testing
@testable import Termind
@Suite
@MainActor
struct SFTPHistoryTests {
@Test
func recordsOnlyPathsAndRefreshesDuplicateRecency() {
let hostID = UUID()
let model = SFTPHistoryModel()
model.record(
kind: .directory, path: "/srv/../srv/app", for: hostID,
at: Date(timeIntervalSince1970: 1)
)
model.record(
kind: .file, path: "/srv/app/config.json", for: hostID,
at: Date(timeIntervalSince1970: 2)
)
model.record(
kind: .directory, path: "/srv/app", for: hostID,
at: Date(timeIntervalSince1970: 3)
)
let entries = model.entries(for: hostID)
#expect(entries.count == 2)
#expect(entries.map(\.kind) == [.directory, .file])
#expect(entries.map(\.path) == ["/srv/app", "/srv/app/config.json"])
}
@Test
func swiftDataRepositoryPersistsAndClearsPerHost() throws {
let configuration = localModelConfiguration(
"SFTPHistoryTests", schema: HostPersistence.schema,
isStoredInMemoryOnly: true
)
let container = try ModelContainer(
for: HostPersistence.schema, configurations: [configuration]
)
let hostID = UUID()
let otherHostID = UUID()
let model = SFTPHistoryModel(modelContext: container.mainContext)
model.record(kind: .directory, path: "/var/log", for: hostID)
model.record(kind: .file, path: "/tmp/a.txt", for: otherHostID)
#expect(model.storageError == nil)
let rowCount = try container.mainContext.fetch(FetchDescriptor<PersistentSFTPHistoryEntry>()).count
#expect(rowCount == 2)
#expect(model.entries(for: hostID).map(\.path) == ["/var/log"])
model.clear(for: hostID)
#expect(model.entries(for: hostID).isEmpty)
#expect(model.entries(for: otherHostID).map(\.path) == ["/tmp/a.txt"])
}
}