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.
This commit is contained in:
1 parent
38981e0648
commit
80f08b089f
18 files changed
+514
-422
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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" : {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String, any Error>
|
||||
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() }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> = [],
|
||||
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: []]
|
||||
|
||||
@@ -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"]] : []))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user