diff --git a/FrameworkTests/SFTPHistoryTests.swift b/FrameworkTests/SFTPHistoryTests.swift new file mode 100644 index 00000000..b1e9afb2 --- /dev/null +++ b/FrameworkTests/SFTPHistoryTests.swift @@ -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"]) + } +} diff --git a/TFM/Application/FeatureModels/SFTPHistoryModel.swift b/Frameworks/TermindApplication/FeatureModels/SFTPHistoryModel.swift similarity index 62% rename from TFM/Application/FeatureModels/SFTPHistoryModel.swift rename to Frameworks/TermindApplication/FeatureModels/SFTPHistoryModel.swift index 62df195d..76d6afe1 100644 --- a/TFM/Application/FeatureModels/SFTPHistoryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/SFTPHistoryModel.swift @@ -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 + } } } diff --git a/TFM/Application/Repositories/SFTPHistoryRepository.swift b/Frameworks/TermindApplication/Repositories/SFTPHistoryRepository.swift similarity index 58% rename from TFM/Application/Repositories/SFTPHistoryRepository.swift rename to Frameworks/TermindApplication/Repositories/SFTPHistoryRepository.swift index 524bf5e1..cdede46f 100644 --- a/TFM/Application/Repositories/SFTPHistoryRepository.swift +++ b/Frameworks/TermindApplication/Repositories/SFTPHistoryRepository.swift @@ -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 } + } } diff --git a/Frameworks/TermindPersistence/SwiftDataSFTPHistoryRepository.swift b/Frameworks/TermindPersistence/SwiftDataSFTPHistoryRepository.swift index 613fe757..6df7e056 100644 --- a/Frameworks/TermindPersistence/SwiftDataSFTPHistoryRepository.swift +++ b/Frameworks/TermindPersistence/SwiftDataSFTPHistoryRepository.swift @@ -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( + 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( + 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( + 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( + 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 } + ) + } + } +} diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index af7f75fe..4543e350 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -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. diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index c68f4972..3bd36a6a 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -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) diff --git a/TFI/Application/Transfers/MobileSFTPSessionModel.swift b/TFI/Application/Transfers/MobileSFTPSessionModel.swift index 37310d03..5bea8a3d 100644 --- a/TFI/Application/Transfers/MobileSFTPSessionModel.swift +++ b/TFI/Application/Transfers/MobileSFTPSessionModel.swift @@ -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) + } } diff --git a/TFI/Features/SFTP/MobileSFTPHistoryView.swift b/TFI/Features/SFTP/MobileSFTPHistoryView.swift new file mode 100644 index 00000000..74f6366d --- /dev/null +++ b/TFI/Features/SFTP/MobileSFTPHistoryView.swift @@ -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) + } +} diff --git a/TFI/Features/Terminal/MobileCommandHistoryView.swift b/TFI/Features/Terminal/MobileCommandHistoryView.swift index ec2af978..f16ffb21 100644 --- a/TFI/Features/Terminal/MobileCommandHistoryView.swift +++ b/TFI/Features/Terminal/MobileCommandHistoryView.swift @@ -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) { diff --git a/TFI/Features/Terminal/MobileTerminalSnippetsView.swift b/TFI/Features/Terminal/MobileTerminalSnippetsView.swift index 84e86759..3dd4a6b7 100644 --- a/TFI/Features/Terminal/MobileTerminalSnippetsView.swift +++ b/TFI/Features/Terminal/MobileTerminalSnippetsView.swift @@ -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) { diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index 035e2f84..567d2039 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -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? 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 { diff --git a/TFI/Features/Workspaces/MobileWorkspaceView.swift b/TFI/Features/Workspaces/MobileWorkspaceView.swift index 694e8734..5cff33f1 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceView.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceView.swift @@ -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") diff --git a/TFITests/MobileAppTests.swift b/TFITests/MobileAppTests.swift index 60f2a55a..41b55a4e 100644 --- a/TFITests/MobileAppTests.swift +++ b/TFITests/MobileAppTests.swift @@ -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 diff --git a/TFITests/MobileCommandGeneratorAndUploadTests.swift b/TFITests/MobileCommandGeneratorAndUploadTests.swift index fe95674e..3c85bc0a 100644 --- a/TFITests/MobileCommandGeneratorAndUploadTests.swift +++ b/TFITests/MobileCommandGeneratorAndUploadTests.swift @@ -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 + + init( + attributes: [String: RemoteFile] = [:], + failedDownloadPaths: Set = [] + ) { + 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") + } + } } diff --git a/TFITests/MobileSFTPHistoryTests.swift b/TFITests/MobileSFTPHistoryTests.swift new file mode 100644 index 00000000..1e97091e --- /dev/null +++ b/TFITests/MobileSFTPHistoryTests.swift @@ -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(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 + } +} diff --git a/TFITests/MobileSFTPSessionTests.swift b/TFITests/MobileSFTPSessionTests.swift index af8caea5..20fa101c 100644 --- a/TFITests/MobileSFTPSessionTests.swift +++ b/TFITests/MobileSFTPSessionTests.swift @@ -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) diff --git a/TFITests/MobileTerminalListTests.swift b/TFITests/MobileTerminalListTests.swift index 03ec6594..96d65a3c 100644 --- a/TFITests/MobileTerminalListTests.swift +++ b/TFITests/MobileTerminalListTests.swift @@ -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.. 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.. [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(of type: T.Type, in root: UIView) -> [T] { var result: [T] = [] var pending = [root] diff --git a/TFM/App/TermindApp.swift b/TFM/App/TermindApp.swift index 240ef7eb..db77331e 100644 --- a/TFM/App/TermindApp.swift +++ b/TFM/App/TermindApp.swift @@ -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 ), diff --git a/TFM/Infrastructure/Database/SwiftDataSFTPHistoryRepository.swift b/TFM/Infrastructure/Database/SwiftDataSFTPHistoryRepository.swift deleted file mode 100644 index 33033a40..00000000 --- a/TFM/Infrastructure/Database/SwiftDataSFTPHistoryRepository.swift +++ /dev/null @@ -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( - 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( - 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( - 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( - 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)) - } -} diff --git a/TFMTests/ArchitectureBoundaryTests.swift b/TFMTests/ArchitectureBoundaryTests.swift index d6d2fe82..11534a82 100644 --- a/TFMTests/ArchitectureBoundaryTests.swift +++ b/TFMTests/ArchitectureBoundaryTests.swift @@ -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", ]) } diff --git a/TFMTests/SFTPHistoryTests.swift b/TFMTests/SFTPHistoryTests.swift deleted file mode 100644 index 0f775a33..00000000 --- a/TFMTests/SFTPHistoryTests.swift +++ /dev/null @@ -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()).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"]) - } -}