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,