TFI Snippets and History
This commit is contained in:
1 parent
5ee3cded0a
commit
e7105ed45d
21 files changed
+817
-237
No files matched your search
@@ -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"])
|
||||
}
|
||||
}
|
||||
+20
-11
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-7
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user