From 80f08b089f0e784d1045529d9fafe0fdb3ca5ef7 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sat, 26 Sep 2026 12:05:26 +0800 Subject: [PATCH] refactor: share transfer presentation and polish workspace actions Centralize transfer state and presentation rules, extract the macOS transfer center, and preserve remote home metadata for compact iOS transfer paths. Correct the shared Close Others action and extend transfer regression coverage. --- .../SFTPTransferPresentationTests.swift | 104 ++++++++ Frameworks/Resources/Localizable.xcstrings | 34 +-- .../FeatureModels/HostLibraryModel.swift | 18 +- .../Transfers/SFTPTransfer.swift | 15 +- .../SFTP/SFTPTransfer+Presentation.swift | 32 +++ .../Strings/Workspace.swift | 4 +- .../Transfers/SFTPTransferCoordinator.swift | 15 +- .../Transfers/MobileSFTPChannelModel.swift | 4 + .../Transfers/MobileTransfersView.swift | 52 +--- TFI/Features/Workspaces/MobileWorkspace.swift | 42 ++- .../Workspaces/MobileWorkspaceView.swift | 2 +- ...MobileCommandGeneratorAndUploadTests.swift | 79 +++++- TFITests/MobileWorkspaceMenuTests.swift | 4 +- TFM/Features/StatusBar/StatusBarView.swift | 248 ------------------ .../StatusBar/TransferCenterPopover.swift | 190 ++++++++++++++ .../Workspaces/WorkspaceNavigatorView.swift | 2 +- TFMTests/SFTPFeatureTests.swift | 18 +- TFMTests/StatusBarPresentationTests.swift | 73 ------ 18 files changed, 514 insertions(+), 422 deletions(-) create mode 100644 FrameworkTests/SFTPTransferPresentationTests.swift create mode 100644 TFM/Features/StatusBar/TransferCenterPopover.swift diff --git a/FrameworkTests/SFTPTransferPresentationTests.swift b/FrameworkTests/SFTPTransferPresentationTests.swift new file mode 100644 index 00000000..f4c7f6d4 --- /dev/null +++ b/FrameworkTests/SFTPTransferPresentationTests.swift @@ -0,0 +1,104 @@ +import Foundation +import TermindDomain +import TermindPresentation +import Testing + +import struct TermindDomain.Host + +@Suite("SFTP transfer presentation") +struct SFTPTransferPresentationTests { + @Test + func uploadAndDownloadRoutesHideLocalPathsAndAbbreviateHomeInEveryState() { + let source = Host(name: "Source", host: "source.example") + let destination = Host(name: "Destination", host: "destination.example") + let names = [source.id: source.name, destination.id: destination.name] + let states: [SFTPTransfer.State] = [ + .queued, .running, .paused, .completed, .cancelled, .failed("Offline"), + ] + + for state in states { + let upload = SFTPTransfer( + sourceHostID: nil, + destinationHostID: destination.id, + direction: .upload, + localPath: "/Users/example/private/upload.txt", + remotePath: "/home/deploy/releases/upload.txt", + destinationHomeDirectory: "/home/deploy", + state: state + ) + let download = SFTPTransfer( + sourceHostID: source.id, + destinationHostID: nil, + direction: .download, + localPath: "/Users/example/private/download.txt", + remotePath: "/home/deploy/backups/download.txt", + sourceHomeDirectory: "/home/deploy", + state: state + ) + + let uploadRoute = upload.routeLabel { names[$0] } + let downloadRoute = download.routeLabel { names[$0] } + + #expect(uploadRoute == "Destination · ~/releases/upload.txt") + #expect(downloadRoute == "Source · ~/backups/download.txt") + #expect(!uploadRoute.contains(upload.localPath)) + #expect(!downloadRoute.contains(download.localPath)) + } + } + + @Test + func remoteCopyRoutesStillShowBothRemoteEndpoints() { + let source = Host(name: "Source", host: "source.example") + let destination = Host(name: "Destination", host: "destination.example") + let names = [source.id: source.name, destination.id: destination.name] + let copy = SFTPTransfer( + sourceHostID: source.id, + destinationHostID: destination.id, + direction: .copy, + localPath: "/home/source/in/file.txt", + remotePath: "/home/destination/out/file.txt", + sourceHomeDirectory: "/home/source", + destinationHomeDirectory: "/home/destination" + ) + + #expect( + copy.routeLabel { names[$0] } + == "Source · ~/in/file.txt → Destination · ~/out/file.txt" + ) + } + + @Test + func transferRoutesFallBackToTheDisplayedRemotePathForUnknownHosts() { + let upload = SFTPTransfer( + destinationHostID: UUID(), + direction: .upload, + localPath: "/Users/example/private/file.txt", + remotePath: "/home/deploy/file.txt", + destinationHomeDirectory: "/home/deploy" + ) + + #expect(upload.routeLabel { _ in nil } == "~/file.txt") + var unresolved = upload + unresolved.destinationHomeDirectory = nil + #expect(unresolved.routeLabel { _ in nil } == "/home/deploy/file.txt") + unresolved.destinationHostID = nil + #expect(unresolved.routeLabel { _ in "Unused" } == "/home/deploy/file.txt") + } + + @Test + func transferDetailPrioritizesFailureThenThroughputThenByteProgress() { + var transfer = SFTPTransfer( + localPath: "/tmp/file.txt", remotePath: "/srv/file.txt", + byteCount: 100, transferredBytes: 25, bytesPerSecond: 10, state: .failed("Offline")) + + #expect(transfer.progressDetail == "Offline") + transfer.state = .running + #expect(transfer.progressDetail == DataSizeFormat.rate(10)) + transfer.bytesPerSecond = 0 + #expect(transfer.progressDetail == "\(DataSizeFormat.bytes(25)) of \(DataSizeFormat.bytes(100))") + transfer.byteCount = 0 + #expect(transfer.progressDetail == nil) + transfer.state = .completed + #expect(transfer.progressDetail == nil) + } +} diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index fc9ce953..1b86c655 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -5594,23 +5594,6 @@ } } }, - "Close Other" : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "关闭其他" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "關閉其他" - } - } - } - }, "Close Other Workspaces" : { "extractionState" : "manual", "localizations" : { @@ -5628,6 +5611,23 @@ } } }, + "Close Others" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "关闭其他" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "關閉其他" + } + } + } + }, "Close Pane" : { "extractionState" : "manual", "localizations" : { diff --git a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift index 51b45501..c37b7b2d 100644 --- a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift @@ -1906,26 +1906,16 @@ public final class HostLibraryModel: RemoteSessionHostStore { } } - /// Clears terminal transfer records while leaving queued and running work + /// Clears terminal transfer records while leaving queued, running and paused work /// untouched. The transfer center is a current-session history, not a log. public func clearFinishedSFTPTransfers() { - sftpTransfers.removeAll { - switch $0.state { - case .completed, .cancelled, .failed: true - case .queued, .running, .paused: false - } - } + sftpTransfers.removeAll { $0.state.isFinished } } /// Removes one terminal record without affecting its downloaded file or live transfers. public func removeFinishedSFTPTransfer(_ id: SFTPTransfer.ID) { - guard let transfer = sftpTransfer(id) else { return } - switch transfer.state { - case .completed, .cancelled, .failed: - sftpTransfers.removeAll { $0.id == id } - case .queued, .running, .paused: - break - } + guard let transfer = sftpTransfer(id), transfer.state.isFinished else { return } + sftpTransfers.removeAll { $0.id == id } } /// Files one or more hosts into a group, preserving the order supplied. diff --git a/Frameworks/TermindDomain/Transfers/SFTPTransfer.swift b/Frameworks/TermindDomain/Transfers/SFTPTransfer.swift index cad187f1..17e8310c 100644 --- a/Frameworks/TermindDomain/Transfers/SFTPTransfer.swift +++ b/Frameworks/TermindDomain/Transfers/SFTPTransfer.swift @@ -1,6 +1,6 @@ import Foundation -/// One queued or running SFTP transfer. +/// One SFTP transfer and its current-session history. /// /// The queue is app-wide rather than per-SFTP workspace: a copy started from one /// browser should keep running when that workspace is closed. The browser session only @@ -29,6 +29,19 @@ public nonisolated struct SFTPTransfer: Identifiable, Hashable, Sendable { case cancelled case failed(String) + /// Work awaiting admission or moving bytes; paused work is not active. + public var isActive: Bool { + self == .queued || self == .running + } + + /// Only terminal records may be removed from the transfer history. + public var isFinished: Bool { + switch self { + case .completed, .cancelled, .failed: true + case .queued, .running, .paused: false + } + } + public var label: String { switch self { case .queued: String(localized: "Queued") diff --git a/Frameworks/TermindPresentation/SFTP/SFTPTransfer+Presentation.swift b/Frameworks/TermindPresentation/SFTP/SFTPTransfer+Presentation.swift index 0444013a..80ade384 100644 --- a/Frameworks/TermindPresentation/SFTP/SFTPTransfer+Presentation.swift +++ b/Frameworks/TermindPresentation/SFTP/SFTPTransfer+Presentation.swift @@ -1,6 +1,8 @@ import Foundation import TermindDomain +import struct TermindDomain.Host + nonisolated extension SFTPTransfer { public var statusLabel: String { switch (direction, state) { @@ -12,4 +14,34 @@ nonisolated extension SFTPTransfer { default: state.label } } + + /// Failure, throughput, or byte progress, in that order. The caller supplies + /// its existing fallback when a transfer has no measurable progress yet. + public var progressDetail: String? { + if case .failed(let reason) = state { return reason } + if bytesPerSecond > 0 { return DataSizeFormat.rate(bytesPerSecond) } + guard byteCount > 0 else { return nil } + return "\(DataSizeFormat.bytes(transferredBytes)) of \(DataSizeFormat.bytes(byteCount))" + } + + /// Shows only remote endpoints, abbreviating each host's resolved home directory. + public func routeLabel(hostName: (Host.ID) -> String?) -> String { + func remote(_ hostID: Host.ID?, _ path: String, home: String?) -> String { + let displayed = RemotePath.displayed( + path, home: home.map(RemoteHomeResolutionState.resolved) ?? .unavailable) + guard let hostID, let name = hostName(hostID) else { return displayed } + return "\(name) · \(displayed)" + } + + switch direction { + case .upload: + return remote(destinationHostID, remotePath, home: destinationHomeDirectory) + case .download: + return remote(sourceHostID, remotePath, home: sourceHomeDirectory) + case .copy: + let source = remote(sourceHostID, localPath, home: sourceHomeDirectory) + let destination = remote(destinationHostID, remotePath, home: destinationHomeDirectory) + return "\(source) → \(destination)" + } + } } diff --git a/Frameworks/TermindPresentation/Strings/Workspace.swift b/Frameworks/TermindPresentation/Strings/Workspace.swift index 077d13bc..e8e89bff 100644 --- a/Frameworks/TermindPresentation/Strings/Workspace.swift +++ b/Frameworks/TermindPresentation/Strings/Workspace.swift @@ -30,8 +30,8 @@ extension AppStrings { "Close All Workspaces" } - public static var closeOther: LocalizedStringResource { - "Close Other" + public static var closeOthers: LocalizedStringResource { + "Close Others" } public static var closeOtherWorkspaces: LocalizedStringResource { diff --git a/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferCoordinator.swift b/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferCoordinator.swift index 84d54d2f..198e45f9 100644 --- a/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferCoordinator.swift +++ b/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferCoordinator.swift @@ -169,7 +169,7 @@ public final class SFTPTransferCoordinator { } public var activeCount: Int { activeTasks.count } public var summary: SFTPTransferSummary { - let active = sftpTransfers.filter { $0.state == .queued || $0.state == .running } + let active = sftpTransfers.filter { $0.state.isActive } let failedCount = sftpTransfers.filter { if case .failed = $0.state { true } else { false } }.count @@ -348,7 +348,7 @@ public final class SFTPTransferCoordinator { public func canPause(_ id: SFTPTransfer.ID) -> Bool { guard let transfer = store.sftpTransfer(id) else { return false } - return runtimes[id] != nil && (transfer.state == .queued || transfer.state == .running) + return runtimes[id] != nil && transfer.state.isActive } public func pause(_ id: SFTPTransfer.ID) { @@ -440,7 +440,7 @@ public final class SFTPTransferCoordinator { } public func pauseAll() { - for transfer in sftpTransfers where transfer.state == .queued || transfer.state == .running { + for transfer in sftpTransfers where transfer.state.isActive { if canPause(transfer.id) { pause(transfer.id) } else { cancel(transfer.id) } } } @@ -453,12 +453,7 @@ public final class SFTPTransferCoordinator { } public func cancelAll() { - let ids = store.sftpTransfers.compactMap { sftpTransfer -> SFTPTransfer.ID? in - switch sftpTransfer.state { - case .queued, .running, .paused: sftpTransfer.id - case .completed, .failed, .cancelled: nil - } - } + let ids = store.sftpTransfers.filter { !$0.state.isFinished }.map(\.id) for id in ids { cancel(id) } } @@ -470,7 +465,7 @@ public final class SFTPTransferCoordinator { public func cancelTransfers(usingBorrowedConnection connectionID: UUID) async { let ids = store.sftpTransfers.compactMap { transfer -> SFTPTransfer.ID? in - guard transfer.state == .queued || transfer.state == .running || transfer.state == .paused, + guard !transfer.state.isFinished, borrowedIDs(for: transfer).contains(connectionID) else { return nil } return transfer.id diff --git a/TFI/Application/Transfers/MobileSFTPChannelModel.swift b/TFI/Application/Transfers/MobileSFTPChannelModel.swift index 42f3a1ce..d13a53b5 100644 --- a/TFI/Application/Transfers/MobileSFTPChannelModel.swift +++ b/TFI/Application/Transfers/MobileSFTPChannelModel.swift @@ -10,6 +10,7 @@ import struct TermindDomain.Host @Observable final class MobileSFTPChannelModel { private(set) var path = "/" + private(set) var homeDirectory: String? var pathText = "/" private(set) var files: [RemoteFile] = [] var query = "" @@ -58,11 +59,13 @@ final class MobileSFTPChannelModel { func attach( fileSystem: any RemoteFileSystem, path: String, + homeDirectory: String? = nil, connectionGeneration: UUID, handlesConnectionError: @escaping (any Error) -> Bool, didChangePath: @escaping () -> Void ) async throws { self.fileSystem = fileSystem + self.homeDirectory = homeDirectory.flatMap { $0.hasPrefix("/") ? RemotePath.normalized($0) : nil } self.connectionGeneration = connectionGeneration self.handlesConnectionError = handlesConnectionError self.didChangePath = didChangePath @@ -80,6 +83,7 @@ final class MobileSFTPChannelModel { func detach() { fileSystem = nil + homeDirectory = nil connectionGeneration = nil directoryRequest = UUID() handlesConnectionError = nil diff --git a/TFI/Features/Transfers/MobileTransfersView.swift b/TFI/Features/Transfers/MobileTransfersView.swift index 22d6cd9c..5c713cc4 100644 --- a/TFI/Features/Transfers/MobileTransfersView.swift +++ b/TFI/Features/Transfers/MobileTransfersView.swift @@ -16,12 +16,7 @@ struct MobileTransfersView: View { } private var hasFinished: Bool { - items.contains { - switch $0.state { - case .completed, .cancelled, .failed: true - case .queued, .running, .paused: false - } - } + items.contains { $0.state.isFinished } } var body: some View { @@ -67,8 +62,7 @@ struct MobileTransfersView: View { } private func transferCard(_ transfer: SFTPTransfer) -> some View { - let active = transfer.state == .queued || transfer.state == .running - return VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 12) { HStack(alignment: .top, spacing: 12) { MobileCardIcon(symbol: transfer.direction.symbol, tint: tint(for: transfer)) VStack(alignment: .leading, spacing: 4) { @@ -77,16 +71,16 @@ struct MobileTransfersView: View { } } - Text(route(for: transfer)) + Text(transfer.routeLabel { hosts.host($0)?.name }) .font(.caption.monospaced()) .lineLimit(2) .truncationMode(.middle) .foregroundStyle(.secondary) - if active { + if transfer.state.isActive { ProgressView(value: transfer.byteCount > 0 ? transfer.progress : nil) } - Text(detail(for: transfer)) + Text(transfer.progressDetail ?? DataSizeFormat.bytes(transfer.byteCount)) .font(.caption.monospacedDigit()) .foregroundStyle(detailTint(for: transfer)) .lineLimit(2) @@ -111,15 +105,14 @@ struct MobileTransfersView: View { transfers.resume(transfer.id) } } - switch transfer.state { - case .queued, .running, .paused: - Button(AppStrings.Common.cancel.swiftUIKey, systemImage: "xmark.circle.fill") { - transfers.cancel(transfer.id) - } - case .completed, .cancelled, .failed: + if transfer.state.isFinished { Button(AppStrings.Common.delete.swiftUIKey, systemImage: "trash", role: .destructive) { transfers.removeFinished(transfer.id) } + } else { + Button(AppStrings.Common.cancel.swiftUIKey, systemImage: "xmark.circle.fill") { + transfers.cancel(transfer.id) + } } } @@ -140,31 +133,6 @@ struct MobileTransfersView: View { } } - private func detail(for transfer: SFTPTransfer) -> String { - if case .failed(let reason) = transfer.state { return reason } - if transfer.bytesPerSecond > 0 { return DataSizeFormat.rate(transfer.bytesPerSecond) } - if transfer.byteCount > 0 { - return "\(DataSizeFormat.bytes(transfer.transferredBytes)) of \(DataSizeFormat.bytes(transfer.byteCount))" - } - return DataSizeFormat.bytes(transfer.byteCount) - } - - private func route(for transfer: SFTPTransfer) -> String { - func remote(_ hostID: Host.ID?, _ path: String) -> String { - guard let hostID, let host = hosts.host(hostID) else { return path } - return "\(host.name) · \(path)" - } - switch transfer.direction { - case .upload: - return "\(transfer.localPath) → \(remote(transfer.destinationHostID, transfer.remotePath))" - case .download: - return "\(remote(transfer.sourceHostID, transfer.remotePath)) → \(transfer.localPath)" - case .copy: - return - "\(remote(transfer.sourceHostID, transfer.localPath)) → \(remote(transfer.destinationHostID, transfer.remotePath))" - } - } - private func tint(for transfer: SFTPTransfer) -> Color { switch transfer.state { case .failed: .red diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index 632530de..1044ceb3 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -23,6 +23,7 @@ private nonisolated enum MobileSSHSessionEvent: Sendable { private struct MobilePendingUpload { var batch: SFTPLocalUploadBatch var destination: String + var homeDirectory: String? var client: any SFTPClientProtocol var ownsClient: Bool var context: SFTPTransferContext @@ -757,13 +758,20 @@ final class MobileWorkspace: Identifiable { return } sftp = client + let homeDirectoryResult: Result + do { + homeDirectoryResult = .success(try await client.homeDirectory()) + } catch { + homeDirectoryResult = .failure(error) + } + guard generation == token, !Task.isCancelled else { return } let initialPath: String if reconnecting { initialPath = sftpChannel.path } else if let requested = externalSFTPResourcePath { let resolved: String if requested == "/~" || requested.hasPrefix("/~/") { - let home = RemotePath.normalized(try await client.homeDirectory()) + let home = RemotePath.normalized(try homeDirectoryResult.get()) resolved = requested == "/~" ? home : RemotePath.join(home, String(requested.dropFirst(3))) } else { resolved = RemotePath.normalized(requested) @@ -786,13 +794,14 @@ final class MobileWorkspace: Identifiable { } else if let pendingInitialSFTPPath { initialPath = pendingInitialSFTPPath } else if host.sftpChannelInitialPath.isEmpty { - initialPath = try await client.homeDirectory() + initialPath = try homeDirectoryResult.get() } else { initialPath = host.sftpChannelInitialPath } try await sftpChannel.attach( fileSystem: client, path: initialPath, + homeDirectory: try? homeDirectoryResult.get(), connectionGeneration: token, handlesConnectionError: { [weak self] error in self?.handleSFTPConnectionError(error, connection: token) == true @@ -1344,6 +1353,17 @@ final class MobileWorkspace: Identifiable { generation == connectionGeneration, uploadDestination == destination, let client else { throw CancellationError() } + let homeDirectory: String? + if descriptor.kind == .sftp { + homeDirectory = sftpChannel.homeDirectory + } else { + let proposed = try? await client.homeDirectory() + homeDirectory = proposed.flatMap { $0.hasPrefix("/") ? RemotePath.normalized($0) : nil } + } + try Task.checkCancellation() + guard uploadPreparationID == preparationID, + generation == connectionGeneration, uploadDestination == destination + else { throw CancellationError() } let batch = try await SFTPLocalUploadPlanner.prepare( urls, destination: destination, @@ -1370,6 +1390,7 @@ final class MobileWorkspace: Identifiable { pendingUpload = MobilePendingUpload( batch: batch, destination: destination, + homeDirectory: homeDirectory, client: client, ownsClient: ownsClient, context: context, @@ -1399,7 +1420,8 @@ final class MobileWorkspace: Identifiable { return SFTPRemoteCopySource( hostID: descriptor.hostID, connection: .context(transferContext), - files: [file] + files: [file], + homeDirectory: sftpChannel.homeDirectory ) } @@ -1497,6 +1519,7 @@ final class MobileWorkspace: Identifiable { pendingRemoteCopy.batch, policy: policy, destinationHostID: descriptor.hostID, destinationConnection: .context(transferContext), + destinationHomeDirectory: sftpChannel.homeDirectory, client: sftp, coordinator: transfers ) { [weak self] state in guard state == .completed else { return } @@ -1517,6 +1540,7 @@ final class MobileWorkspace: Identifiable { pendingDownload.batch, policy: policy, hostID: descriptor.hostID, connection: .context(transferContext), + homeDirectory: sftpChannel.homeDirectory, coordinator: transfers ) { [sftpHistory, hostID = descriptor.hostID] path, state in guard state == .completed else { return } @@ -1557,6 +1581,7 @@ final class MobileWorkspace: Identifiable { policy: policy, hostID: descriptor.hostID, connection: .context(pendingUpload.context), + homeDirectory: pendingUpload.homeDirectory, client: pendingUpload.client, coordinator: transfers ) { [weak self] state in @@ -1646,7 +1671,8 @@ final class MobileWorkspace: Identifiable { Task { do { let url = try await sftpEdits.open( - file, hostID: descriptor.hostID, connection: .context(transferContext), client: sftp) + file, hostID: descriptor.hostID, connection: .context(transferContext), client: sftp, + homeDirectory: sftpChannel.homeDirectory) if connectionOrigin.allowsPersistence { sftpHistory.record(kind: .file, path: file.path, for: descriptor.hostID) } @@ -1666,7 +1692,8 @@ final class MobileWorkspace: Identifiable { transfers.enqueue( direction: .download, batchID: UUID(), localURL: localURL, remotePath: file.path, byteCount: file.size, hostID: descriptor.hostID, - connection: .context(transferContext), scopedRoot: directory, overwrite: false + connection: .context(transferContext), scopedRoot: directory, overwrite: false, + homeDirectory: sftpChannel.homeDirectory ) { [sftpHistory, hostID = descriptor.hostID, path = file.path] state in guard state == .completed else { return } if self.connectionOrigin.allowsPersistence { @@ -1683,7 +1710,10 @@ final class MobileWorkspace: Identifiable { func uploadEdit(_ id: UUID, replacing: Bool) async -> Bool { guard connected, let sftpEdits, let sftp, let transferContext else { return false } - return await sftpEdits.upload(id, connection: .context(transferContext), client: sftp, replacing: replacing) { + return await sftpEdits.upload( + id, connection: .context(transferContext), client: sftp, + homeDirectory: sftpChannel.homeDirectory, replacing: replacing + ) { [weak self] in Task { await self?.sftpChannel.reload() } } diff --git a/TFI/Features/Workspaces/MobileWorkspaceView.swift b/TFI/Features/Workspaces/MobileWorkspaceView.swift index f49c45e8..add779ee 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceView.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceView.swift @@ -775,7 +775,7 @@ struct MobileWorkspaceLifecycleMenuActions: View { if canCloseMultipleWorkspaces { Divider() Button( - AppStrings.Workspace.closeOther.swiftUIKey, systemImage: "xmark", role: .destructive, + AppStrings.Workspace.closeOthers.swiftUIKey, systemImage: "xmark", role: .destructive, action: closeOthers) } } diff --git a/TFITests/MobileCommandGeneratorAndUploadTests.swift b/TFITests/MobileCommandGeneratorAndUploadTests.swift index e565c102..cfe8366a 100644 --- a/TFITests/MobileCommandGeneratorAndUploadTests.swift +++ b/TFITests/MobileCommandGeneratorAndUploadTests.swift @@ -708,13 +708,81 @@ struct MobileUploadBatchTests { #expect(workspace.uploadNotice == nil) } + @Test(arguments: [ConnectionHistoryEntry.Kind.ssh, .sftp]) + func transferRoutesRetainTheRemoteHomeAfterWorkspaceClosure(_ kind: ConnectionHistoryEntry.Kind) async throws { + let workspace = uploadWorkspace(client: MobileUploadSFTPClient(), kind: kind) + defer { workspace.disconnect() } + workspace.connect() + #expect(await eventually { workspace.connected }) + if kind == .ssh { workspace.receiveWorkingDirectory("/home/test") } + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appending(path: "report.txt") + try Data("report".utf8).write(to: file) + + #expect(workspace.prepareUpload([file])) + let transfers = try #require(workspace.transfers) + #expect(await eventually { transfers.sftpTransfers.first?.state == .completed }) + workspace.disconnect() + + let transfer = try #require(transfers.sftpTransfers.first) + #expect(transfer.destinationHomeDirectory == "/home/test") + #expect(transfer.routeLabel { _ in "Upload" } == "Upload · ~/report.txt") + #expect(workspace.sftpChannel.homeDirectory == nil) + } + + @Test func downloadsAndRemoteCopiesRetainResolvedHomeDirectories() async throws { + let workspace = uploadWorkspace(client: MobileUploadSFTPClient(), kind: .sftp) + defer { workspace.disconnect() } + workspace.connect() + #expect(await eventually { workspace.connected }) + let transfers = try #require(workspace.transfers) + let file = RemoteFile( + path: "/home/test/report.txt", name: "report.txt", kind: .file, size: 0, + permissions: 0o644, ownerUID: 501, ownerGID: 20) + let destination = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: destination) } + + workspace.prepareDownload(file, to: destination) + #expect(await eventually { transfers.sftpTransfers.first?.state == .completed }) + let download = try #require(transfers.sftpTransfers.first) + #expect(download.sourceHomeDirectory == "/home/test") + #expect(download.routeLabel { _ in "Upload" } == "Upload · ~/report.txt") + + let source = try #require(workspace.remoteCopySource(for: file)) + let payload = transfers.registerRemoteCopySource(source) + await workspace.sftpChannel.openDirectory("/home/test/archive") + workspace.prepareRemoteCopy(payload) + #expect(await eventually { transfers.sftpTransfers.contains { $0.direction == .copy } }) + let copy = try #require(transfers.sftpTransfers.first { $0.direction == .copy }) + #expect(copy.sourceHomeDirectory == "/home/test") + #expect(copy.destinationHomeDirectory == "/home/test") + #expect(copy.routeLabel { _ in "Upload" } == "Upload · ~/report.txt → Upload · ~/archive/report.txt") + transfers.cancelAll() + await transfers.waitForCancellation() + } + + @Test func unavailableHomeDoesNotPreventConnectingToAnExplicitDirectory() async throws { + let workspace = uploadWorkspace( + client: MobileUploadSFTPClient(homeDirectory: nil), kind: .sftp, initialPath: "/srv") + defer { workspace.disconnect() } + workspace.connect() + #expect(await eventually { workspace.connected }) + #expect(workspace.sftpChannel.path == "/srv") + #expect(workspace.sftpChannel.homeDirectory == nil) + } + private func uploadWorkspace( client: MobileUploadSFTPClient, kind: ConnectionHistoryEntry.Kind = .ssh, + initialPath: String = "", open: (() async throws -> any SFTPClientProtocol)? = nil ) -> MobileWorkspace { let identity = Identity(name: "Upload", username: "user") - let host = Host(name: "Upload", host: "example.invalid", identityID: identity.id) + let host = Host( + name: "Upload", host: "example.invalid", identityID: identity.id, sftpChannelInitialPath: initialPath) let library = HostLibraryModel(hosts: [host], identities: [identity]) let transfers = SFTPTransferCoordinator(store: library) { client } let services = MobileWorkspaceConnectionServices( @@ -1008,6 +1076,7 @@ struct MobileUploadBatchTests { }) #expect(!history.entries(for: host.id).contains { $0.path == broken.path }) + #expect(transfers.sftpTransfers.allSatisfy { $0.sourceHomeDirectory == "/home/test" }) #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 }) @@ -1181,6 +1250,7 @@ private actor MobileUploadSFTPClient: SFTPClientProtocol { private(set) var removedPaths: [String] = [] private(set) var disconnectCount = 0 private(set) var startedUploads = 0 + private let resolvedHomeDirectory: String? private let blocksUpload: Bool private let requiresInteraction: Bool private let failureStage: UploadFailureStage? @@ -1190,12 +1260,14 @@ private actor MobileUploadSFTPClient: SFTPClientProtocol { private let children: [String: [RemoteFile]] init( + homeDirectory: String? = "/home/test", attributes: [String: RemoteFile] = [:], failedDownloadPaths: Set = [], children: [String: [RemoteFile]] = [:], blocksUpload: Bool = false, requiresInteraction: Bool = false, failureStage: UploadFailureStage? = nil ) { + self.resolvedHomeDirectory = homeDirectory self.blocksUpload = blocksUpload self.requiresInteraction = requiresInteraction self.failureStage = failureStage @@ -1213,7 +1285,10 @@ private actor MobileUploadSFTPClient: SFTPClientProtocol { for waiter in pending { waiter.resume() } } func disconnect() async { disconnectCount += 1 } - func homeDirectory() async throws -> String { "/home/test" } + func homeDirectory() async throws -> String { + guard let resolvedHomeDirectory else { throw SFTPError.operation("Home unavailable") } + return resolvedHomeDirectory + } func listDirectory(at path: String) async throws -> [RemoteFile] { listedDirectories.append(path) return children[path, default: []] diff --git a/TFITests/MobileWorkspaceMenuTests.swift b/TFITests/MobileWorkspaceMenuTests.swift index b060540d..2b6f3e5c 100644 --- a/TFITests/MobileWorkspaceMenuTests.swift +++ b/TFITests/MobileWorkspaceMenuTests.swift @@ -41,7 +41,7 @@ struct MobileWorkspaceMenuTests { let actions = menuActions(menu) let expected = canCloseMultipleWorkspaces - ? ["Rename", "Close", "Close Other"] + ? ["Rename", "Close", "Close Others"] : ["Rename", "Close"] #expect(actions.map(\.title) == expected) #expect(!actions[0].attributes.contains(.destructive)) @@ -54,7 +54,7 @@ struct MobileWorkspaceMenuTests { let separatedActions = menu.children.compactMap { $0 as? UIMenu } .filter { $0.options.contains(.displayInline) } .map { menuActions($0).map(\.title) } - #expect(separatedActions == (canCloseMultipleWorkspaces ? [["Close Other"]] : [])) + #expect(separatedActions == (canCloseMultipleWorkspaces ? [["Close Others"]] : [])) } } diff --git a/TFM/Features/StatusBar/StatusBarView.swift b/TFM/Features/StatusBar/StatusBarView.swift index 6b3c02db..caa76063 100644 --- a/TFM/Features/StatusBar/StatusBarView.swift +++ b/TFM/Features/StatusBar/StatusBarView.swift @@ -51,42 +51,6 @@ nonisolated enum StatusBarTransferPresentation { if directions == [.download] { return "arrow.down" } return "arrow.up.arrow.down" } - - static func route( - for transfer: SFTPTransfer, - hostName: (Host.ID) -> String? - ) -> String { - func remote(_ hostID: Host.ID?, _ path: String, home: String?) -> String { - let displayed = RemotePath.displayed( - path, home: home.map(RemoteHomeResolutionState.resolved) ?? .unavailable - ) - guard let hostID, let name = hostName(hostID) else { return displayed } - return "\(name) · \(displayed)" - } - - switch transfer.direction { - case .upload: - return remote( - transfer.destinationHostID, transfer.remotePath, - home: transfer.destinationHomeDirectory - ) - case .download: - return remote( - transfer.sourceHostID, transfer.remotePath, - home: transfer.sourceHomeDirectory - ) - case .copy: - let source = remote( - transfer.sourceHostID, transfer.localPath, - home: transfer.sourceHomeDirectory - ) - let destination = remote( - transfer.destinationHostID, transfer.remotePath, - home: transfer.destinationHomeDirectory - ) - return "\(source) → \(destination)" - } - } } /// Focused-session context and app-wide transfers, as one floating capsule. @@ -523,215 +487,3 @@ private struct TransferCenterButton: View { return String(localized: AppStrings.SFTP.transfers) } } - -private struct TransferCenterPopover: View { - let coordinator: SFTPTransferCoordinator - let hosts: HostLibraryModel - let dismiss: () -> Void - @State private var confirmsClearFinished = false - - private var transfers: [SFTPTransfer] { - coordinator.sftpTransfers.sorted { $0.queuedAt > $1.queuedAt } - } - private var hasFinished: Bool { - transfers.contains { - switch $0.state { - case .completed, .cancelled, .failed: true - case .queued, .running, .paused: false - } - } - } - - var body: some View { - VStack(spacing: 0) { - HStack { - Text(AppStrings.SFTP.transfers).font(.headline) - Spacer() - if hasFinished { - Button(AppStrings.Common.clear.swiftUIKey) { - confirmsClearFinished = true - } - .buttonStyle(.plain) - .termindInputAwareFocusEffect() - .foregroundStyle(.tint) - } - } - .padding(.horizontal, Metrics.Space.l) - .padding(.vertical, Metrics.Space.l) - - Divider() - - ScrollView { - LazyVStack(spacing: 0) { - ForEach(transferItems) { item in - VStack(spacing: 0) { - TransferCenterRow( - transfer: item.transfer, - route: item.route, - canPause: coordinator.canPause(item.id), - pause: { coordinator.pause(item.id) }, - resume: { coordinator.resume(item.id) }, - cancel: { coordinator.cancel(item.id) }, - delete: { - coordinator.removeFinished(item.id) - if coordinator.sftpTransfers.isEmpty { dismiss() } - } - ) - if item.id != transferItems.last?.id { Divider() } - } - } - } - } - .frame(height: Metrics.TransferCenter.listHeight(itemCount: transferItems.count)) - } - .frame(width: Metrics.TransferCenter.width) - .confirmationDialog( - AppStrings.SFTP.clearFinishedTransfersConfirmation, isPresented: $confirmsClearFinished, - titleVisibility: .visible - ) { - Button(AppStrings.SFTP.clearFinished.swiftUIKey, role: .destructive) { - coordinator.clearFinished() - if coordinator.sftpTransfers.isEmpty { dismiss() } - } - Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) {} - } - } - - private var transferItems: [TransferCenterListItem] { - transfers.map { transfer in - TransferCenterListItem( - transfer: transfer, - route: StatusBarTransferPresentation.route(for: transfer) { hosts.host($0)?.name } - ) - } - } -} - -nonisolated struct TransferCenterListItem: Identifiable, Hashable, Sendable { - let transfer: SFTPTransfer - let route: String - var id: SFTPTransfer.ID { transfer.id } -} - -private struct TransferCenterRow: View { - let transfer: SFTPTransfer - let route: String - let canPause: Bool - let pause: () -> Void - let resume: () -> Void - let cancel: () -> Void - let delete: () -> Void - - private var isActive: Bool { - transfer.state == .queued || transfer.state == .running - } - private var isFailure: Bool { - if case .failed = transfer.state { true } else { false } - } - private var isFinished: Bool { - switch transfer.state { - case .completed, .cancelled, .failed: true - case .queued, .running, .paused: false - } - } - private var detail: String { - if case .failed(let reason) = transfer.state { return reason } - if transfer.bytesPerSecond > 0 { - return DataSizeFormat.rate(transfer.bytesPerSecond) - } - if transfer.byteCount > 0 { - let transferred = DataSizeFormat.bytes(transfer.transferredBytes) - let total = DataSizeFormat.bytes(transfer.byteCount) - return "\(transferred) of \(total)" - } - return transfer.statusLabel - } - - var body: some View { - VStack(alignment: .leading, spacing: Metrics.Space.xs) { - HStack(alignment: .top, spacing: Metrics.Space.l) { - Image(systemName: transfer.direction.symbol) - .font(.system(size: 15, weight: .medium)) - .foregroundStyle(iconStyle) - .frame(width: 30, height: 30) - .background( - .primary.opacity(0.055), - in: .rect(cornerRadius: Metrics.Radius.medium) - ) - - VStack(alignment: .leading, spacing: Metrics.Space.xs) { - Text(transfer.fileName) - .font(.body.weight(.medium)) - .lineLimit(1) - Text(transfer.statusLabel) - .font(.caption) - .foregroundStyle(stateStyle) - .lineLimit(1) - } - Spacer(minLength: Metrics.Space.s) - - if isActive { - controlButton("xmark.circle.fill", title: AppStrings.SFTP.cancelTransfer, action: cancel) - } else if transfer.state == .paused { - controlButton("play.circle", title: AppStrings.SFTP.resumeTransfer, action: resume) - } - } - - Text(route) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - - if isActive { - ProgressView(value: transfer.progress) - .progressViewStyle(.linear) - } - Text(detail) - .font(.caption2.monospacedDigit()) - .foregroundStyle(detailStyle) - .lineLimit(isFailure ? 2 : 1) - } - .padding(.vertical, Metrics.Space.m) - .frame(maxWidth: .infinity, minHeight: Metrics.TransferCenter.estimatedRowHeight, alignment: .topLeading) - .padding(.horizontal, Metrics.Space.l) - .contentShape(.rect) - .contextMenu { - if canPause { - Button(AppStrings.Metrics.pause.swiftUIKey, systemImage: "pause.circle", action: pause) - } else if transfer.state == .paused { - Button(AppStrings.Common.cancel.swiftUIKey, systemImage: "xmark.circle.fill", action: cancel) - } else if isFinished { - Button(AppStrings.Common.delete.swiftUIKey, systemImage: "trash", role: .destructive, action: delete) - } - } - } - - private func controlButton( - _ symbol: String, title: LocalizedStringResource, action: @escaping () -> Void - ) -> some View { - Button(action: action) { - Image(systemName: symbol) - .font(.system(size: 18)) - .foregroundStyle(.secondary) - .frame(width: 28, height: 28) - .contentShape(.rect) - } - .buttonStyle(.plain) - .termindInputAwareFocusEffect() - .help(title.swiftUIKey) - .accessibilityLabel(title.swiftUIKey) - } - - private var iconStyle: AnyShapeStyle { - if isFailure { return AnyShapeStyle(.orange) } - if transfer.state == .completed { return AnyShapeStyle(.green) } - return AnyShapeStyle(.tint) - } - private var stateStyle: AnyShapeStyle { - isFailure ? AnyShapeStyle(.orange) : AnyShapeStyle(.secondary) - } - private var detailStyle: AnyShapeStyle { - isFailure ? AnyShapeStyle(.orange) : AnyShapeStyle(.tertiary) - } -} diff --git a/TFM/Features/StatusBar/TransferCenterPopover.swift b/TFM/Features/StatusBar/TransferCenterPopover.swift new file mode 100644 index 00000000..926ad322 --- /dev/null +++ b/TFM/Features/StatusBar/TransferCenterPopover.swift @@ -0,0 +1,190 @@ +import SwiftUI +import TermindApplication +import TermindDomain +import TermindPresentation +import TermindRemoteInfrastructure + +struct TransferCenterPopover: View { + let coordinator: SFTPTransferCoordinator + let hosts: HostLibraryModel + let dismiss: () -> Void + @State private var confirmsClearFinished = false + + private var transfers: [SFTPTransfer] { + coordinator.sftpTransfers.sorted { $0.queuedAt > $1.queuedAt } + } + private var hasFinished: Bool { + transfers.contains { $0.state.isFinished } + } + + var body: some View { + VStack(spacing: 0) { + HStack { + Text(AppStrings.SFTP.transfers).font(.headline) + Spacer() + if hasFinished { + Button(AppStrings.Common.clear.swiftUIKey) { + confirmsClearFinished = true + } + .buttonStyle(.plain) + .termindInputAwareFocusEffect() + .foregroundStyle(.tint) + } + } + .padding(.horizontal, Metrics.Space.l) + .padding(.vertical, Metrics.Space.l) + + Divider() + + ScrollView { + LazyVStack(spacing: 0) { + ForEach(transferItems) { item in + VStack(spacing: 0) { + TransferCenterRow( + transfer: item.transfer, + route: item.route, + canPause: coordinator.canPause(item.id), + pause: { coordinator.pause(item.id) }, + resume: { coordinator.resume(item.id) }, + cancel: { coordinator.cancel(item.id) }, + delete: { + coordinator.removeFinished(item.id) + if coordinator.sftpTransfers.isEmpty { dismiss() } + } + ) + if item.id != transferItems.last?.id { Divider() } + } + } + } + } + .frame(height: Metrics.TransferCenter.listHeight(itemCount: transferItems.count)) + } + .frame(width: Metrics.TransferCenter.width) + .confirmationDialog( + AppStrings.SFTP.clearFinishedTransfersConfirmation, isPresented: $confirmsClearFinished, + titleVisibility: .visible + ) { + Button(AppStrings.SFTP.clearFinished.swiftUIKey, role: .destructive) { + coordinator.clearFinished() + if coordinator.sftpTransfers.isEmpty { dismiss() } + } + Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) {} + } + } + + private var transferItems: [TransferCenterListItem] { + transfers.map { transfer in + TransferCenterListItem( + transfer: transfer, + route: transfer.routeLabel { hosts.host($0)?.name } + ) + } + } +} + +nonisolated struct TransferCenterListItem: Identifiable, Hashable, Sendable { + let transfer: SFTPTransfer + let route: String + var id: SFTPTransfer.ID { transfer.id } +} + +private struct TransferCenterRow: View { + let transfer: SFTPTransfer + let route: String + let canPause: Bool + let pause: () -> Void + let resume: () -> Void + let cancel: () -> Void + let delete: () -> Void + + private var isFailure: Bool { + if case .failed = transfer.state { true } else { false } + } + var body: some View { + VStack(alignment: .leading, spacing: Metrics.Space.xs) { + HStack(alignment: .top, spacing: Metrics.Space.l) { + Image(systemName: transfer.direction.symbol) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(iconStyle) + .frame(width: 30, height: 30) + .background( + .primary.opacity(0.055), + in: .rect(cornerRadius: Metrics.Radius.medium) + ) + + VStack(alignment: .leading, spacing: Metrics.Space.xs) { + Text(transfer.fileName) + .font(.body.weight(.medium)) + .lineLimit(1) + Text(transfer.statusLabel) + .font(.caption) + .foregroundStyle(stateStyle) + .lineLimit(1) + } + Spacer(minLength: Metrics.Space.s) + + if transfer.state.isActive { + controlButton("xmark.circle.fill", title: AppStrings.SFTP.cancelTransfer, action: cancel) + } else if transfer.state == .paused { + controlButton("play.circle", title: AppStrings.SFTP.resumeTransfer, action: resume) + } + } + + Text(route) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + + if transfer.state.isActive { + ProgressView(value: transfer.progress) + .progressViewStyle(.linear) + } + Text(transfer.progressDetail ?? transfer.statusLabel) + .font(.caption2.monospacedDigit()) + .foregroundStyle(detailStyle) + .lineLimit(isFailure ? 2 : 1) + } + .padding(.vertical, Metrics.Space.m) + .frame(maxWidth: .infinity, minHeight: Metrics.TransferCenter.estimatedRowHeight, alignment: .topLeading) + .padding(.horizontal, Metrics.Space.l) + .contentShape(.rect) + .contextMenu { + if canPause { + Button(AppStrings.Metrics.pause.swiftUIKey, systemImage: "pause.circle", action: pause) + } else if transfer.state == .paused { + Button(AppStrings.Common.cancel.swiftUIKey, systemImage: "xmark.circle.fill", action: cancel) + } else if transfer.state.isFinished { + Button(AppStrings.Common.delete.swiftUIKey, systemImage: "trash", role: .destructive, action: delete) + } + } + } + + private func controlButton( + _ symbol: String, title: LocalizedStringResource, action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Image(systemName: symbol) + .font(.system(size: 18)) + .foregroundStyle(.secondary) + .frame(width: 28, height: 28) + .contentShape(.rect) + } + .buttonStyle(.plain) + .termindInputAwareFocusEffect() + .help(title.swiftUIKey) + .accessibilityLabel(title.swiftUIKey) + } + + private var iconStyle: AnyShapeStyle { + if isFailure { return AnyShapeStyle(.orange) } + if transfer.state == .completed { return AnyShapeStyle(.green) } + return AnyShapeStyle(.tint) + } + private var stateStyle: AnyShapeStyle { + isFailure ? AnyShapeStyle(.orange) : AnyShapeStyle(.secondary) + } + private var detailStyle: AnyShapeStyle { + isFailure ? AnyShapeStyle(.orange) : AnyShapeStyle(.tertiary) + } +} diff --git a/TFM/Features/Workspaces/WorkspaceNavigatorView.swift b/TFM/Features/Workspaces/WorkspaceNavigatorView.swift index c7f47f8a..3631ac11 100644 --- a/TFM/Features/Workspaces/WorkspaceNavigatorView.swift +++ b/TFM/Features/Workspaces/WorkspaceNavigatorView.swift @@ -648,7 +648,7 @@ private struct WorkspaceNavigatorRow: View { } ) entries.append(.separator) - // Close Other and Close All are the same verb as the item above them, + // Close Others and Close All are the same verb as the item above them, // so only the first of the run carries a glyph. entries.append( NativeMenuEntry( diff --git a/TFMTests/SFTPFeatureTests.swift b/TFMTests/SFTPFeatureTests.swift index 7610aad3..393990a6 100644 --- a/TFMTests/SFTPFeatureTests.swift +++ b/TFMTests/SFTPFeatureTests.swift @@ -56,13 +56,21 @@ struct SFTPFeatureTests { localPath: "/tmp/second", remotePath: "/srv/second", byteCount: 300, transferredBytes: 0, state: .queued ) + let paused = SFTPTransfer( + hostID: host.id, localPath: "/tmp/paused", remotePath: "/paused", + byteCount: 1_000, transferredBytes: 500, state: .paused + ) + let finished = SFTPTransfer( + hostID: host.id, localPath: "/tmp/finished", remotePath: "/finished", + byteCount: 1_000, transferredBytes: 1_000, state: .completed + ) let failed = SFTPTransfer( hostID: host.id, direction: .download, localPath: "/tmp/failed", remotePath: "/srv/failed", byteCount: 10, transferredBytes: 4, state: .failed("Network lost") ) let store = HostLibraryModel( - hosts: [host], sftpTransfers: [first, second, failed] + hosts: [host], sftpTransfers: [first, second, paused, finished, failed] ) let coordinator = SFTPTransferCoordinator(store: store) @@ -82,6 +90,10 @@ struct SFTPFeatureTests { hostID: host.id, localPath: "/tmp/running", remotePath: "/running", state: .running ) + let paused = SFTPTransfer( + hostID: host.id, localPath: "/tmp/paused", remotePath: "/paused", + state: .paused + ) let completed = SFTPTransfer( hostID: host.id, localPath: "/tmp/completed", remotePath: "/completed", state: .completed @@ -96,13 +108,13 @@ struct SFTPFeatureTests { ) let store = HostLibraryModel( hosts: [host], - sftpTransfers: [queued, running, completed, cancelled, failed] + sftpTransfers: [queued, running, paused, completed, cancelled, failed] ) let coordinator = SFTPTransferCoordinator(store: store) coordinator.clearFinished() - #expect(store.sftpTransfers.map(\.id) == [queued.id, running.id]) + #expect(store.sftpTransfers.map(\.id) == [queued.id, running.id, paused.id]) } @Test diff --git a/TFMTests/StatusBarPresentationTests.swift b/TFMTests/StatusBarPresentationTests.swift index b21713c5..a6f1f98f 100644 --- a/TFMTests/StatusBarPresentationTests.swift +++ b/TFMTests/StatusBarPresentationTests.swift @@ -160,79 +160,6 @@ struct StatusBarPresentationTests { #expect(NSImage(systemSymbolName: inactiveSymbol, accessibilityDescription: nil) != nil) } - @Test - func uploadAndDownloadRoutesHideLocalPathsInEveryState() { - let source = Host(name: "Source", host: "source.example") - let destination = Host(name: "Destination", host: "destination.example") - let names = [source.id: source.name, destination.id: destination.name] - let states: [SFTPTransfer.State] = [ - .queued, .running, .paused, .completed, .cancelled, .failed("Offline"), - ] - - for state in states { - let upload = SFTPTransfer( - sourceHostID: nil, - destinationHostID: destination.id, - direction: .upload, - localPath: "/Users/example/private/upload.txt", - remotePath: "/home/deploy/releases/upload.txt", - destinationHomeDirectory: "/home/deploy", - state: state - ) - let download = SFTPTransfer( - sourceHostID: source.id, - destinationHostID: nil, - direction: .download, - localPath: "/Users/example/private/download.txt", - remotePath: "/home/deploy/backups/download.txt", - sourceHomeDirectory: "/home/deploy", - state: state - ) - - let uploadRoute = StatusBarTransferPresentation.route(for: upload) { names[$0] } - let downloadRoute = StatusBarTransferPresentation.route(for: download) { names[$0] } - - #expect(uploadRoute == "Destination · ~/releases/upload.txt") - #expect(downloadRoute == "Source · ~/backups/download.txt") - #expect(!uploadRoute.contains(upload.localPath)) - #expect(!downloadRoute.contains(download.localPath)) - } - } - - @Test - func remoteCopyRoutesStillShowBothRemoteEndpoints() { - let source = Host(name: "Source", host: "source.example") - let destination = Host(name: "Destination", host: "destination.example") - let names = [source.id: source.name, destination.id: destination.name] - let copy = SFTPTransfer( - sourceHostID: source.id, - destinationHostID: destination.id, - direction: .copy, - localPath: "/home/source/in/file.txt", - remotePath: "/home/destination/out/file.txt", - sourceHomeDirectory: "/home/source", - destinationHomeDirectory: "/home/destination" - ) - - #expect( - StatusBarTransferPresentation.route(for: copy) { names[$0] } - == "Source · ~/in/file.txt → Destination · ~/out/file.txt" - ) - } - - @Test - func transferRoutesFallBackToTheDisplayedRemotePathForUnknownHosts() { - let upload = SFTPTransfer( - destinationHostID: UUID(), - direction: .upload, - localPath: "/Users/example/private/file.txt", - remotePath: "/home/deploy/file.txt", - destinationHomeDirectory: "/home/deploy" - ) - - #expect(StatusBarTransferPresentation.route(for: upload) { _ in nil } == "~/file.txt") - } - private func transfer( direction: SFTPTransfer.Direction, state: SFTPTransfer.State,