aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift202
-rw-r--r--Hotline/Library/Extensions.swift5
-rw-r--r--Hotline/Library/NetSocket/FileProgress.swift15
-rw-r--r--Hotline/Library/NetSocket/TransferRateEstimator.swift3
-rw-r--r--Hotline/Library/Views/FileIconView.swift12
-rw-r--r--Hotline/Models/TransferInfo.swift24
-rw-r--r--Hotline/State/HotlineState.swift19
-rw-r--r--Hotline/macOS/TransfersView.swift224
8 files changed, 270 insertions, 234 deletions
diff --git a/Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift b/Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift
index 36193bd..a915043 100644
--- a/Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift
+++ b/Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift
@@ -14,13 +14,14 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient {
private let serverPort: UInt16
private let referenceNumber: UInt32
- private let transferTotal: Int
- private let folderItemCount: Int
- private var transferSize: Int = 0
+ private let transferTotal: Int // Total byte size of all files in folder.
+ private let folderItemCount: Int // Total numbner of items in the folder hierarchy.
private var socket: NetSocket?
private var downloadTask: Task<URL, Error>?
private var folderProgress: Progress?
+
+ private var estimator: TransferRateEstimator
public init(
address: String,
@@ -34,6 +35,8 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient {
self.referenceNumber = reference
self.transferTotal = Int(size)
self.folderItemCount = itemCount
+
+ self.estimator = TransferRateEstimator(total: self.transferTotal)
}
// MARK: - API
@@ -41,8 +44,10 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient {
public func download(
to location: HotlineDownloadLocation,
progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil,
- itemProgress itemProgressHandler: (@Sendable (HotlineFolderItemProgress) -> Void)? = nil
+ items itemProgressHandler: (@Sendable (HotlineFolderItemProgress) -> Void)? = nil
) async throws -> URL {
+ progressHandler?(.preparing)
+
self.downloadTask?.cancel()
let task = Task<URL, Error> {
@@ -59,7 +64,7 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient {
self.downloadTask = nil
return url
} catch {
- print("HotlineFolderDownloadClient[\(referenceNumber)]: Failed to download folder: \(error)")
+ print("HotlineFolderDownloadClient[\(self.referenceNumber)]: Failed to download folder: \(error)")
self.downloadTask = nil
progressHandler?(.error(error))
throw error
@@ -68,10 +73,10 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient {
/// Cancel the current download
public func cancel() {
- downloadTask?.cancel()
- downloadTask = nil
+ self.downloadTask?.cancel()
+ self.downloadTask = nil
- if let socket = socket {
+ if let socket = self.socket {
Task {
await socket.close()
}
@@ -91,7 +96,10 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient {
progressHandler?(.connecting)
// Connect to transfer server
- let socket = try await connectToTransferServer()
+ let socket = try await NetSocket.connect(
+ host: self.serverAddress,
+ port: self.serverPort + 1
+ )
self.socket = socket
defer { Task { await socket.close() } }
@@ -104,12 +112,11 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient {
destinationURL = url
destinationFilename = url.lastPathComponent
case .downloads(let filename):
- let downloadsURL = fm.urls(for: .downloadsDirectory, in: .userDomainMask)[0]
- destinationURL = URL(filePath: downloadsURL.generateUniqueFilePath(filename: filename))
+ destinationURL = URL.downloadsDirectory.generateUniqueFileURL(filename: filename)
destinationFilename = destinationURL.lastPathComponent
}
- print("HotlineFolderDownloadClient[\(referenceNumber)]: Downloading folder to \(destinationURL.path)")
+ print("HotlineFolderDownloadClient[\(self.referenceNumber)]: Downloading folder to \(destinationURL.path)")
// Create destination folder
try? fm.removeItem(at: destinationURL)
@@ -120,10 +127,10 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient {
progress.fileURL = destinationURL
progress.fileOperationKind = .downloading
progress.publish()
+ defer { progress.unpublish() }
self.folderProgress = progress
// Send initial magic header
- print("HotlineFolderDownloadClient[\(self.referenceNumber)]: Sending HTXF magic")
try await socket.write(Data(endian: .big) {
"HTXF".fourCharCode()
self.referenceNumber
@@ -137,7 +144,6 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient {
progressHandler?(.transfer(name: destinationFilename, size: 0, total: self.transferTotal, progress: 0.0, speed: nil, estimate: nil))
var completedItemCount = 0
- var totalBytesTransferred = 0
// Process each item in the folder
while completedItemCount < self.folderItemCount {
@@ -146,15 +152,12 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient {
let headerLen = Int(headerLenData.readUInt16(at: 0)!)
let headerData = try await socket.read(headerLen)
- totalBytesTransferred += 2 + headerLen
+ self.updateProgress(2 + headerLen)
guard let (itemType, pathComponents) = self.parseItemHeaderPath(headerData) else {
throw HotlineTransferClientError.failedToTransfer
}
- let joinedPath = pathComponents.joined(separator: "/")
- print("HotlineFolderDownloadClient[\(referenceNumber)]: Item type=\(itemType) path=\(joinedPath)")
-
if itemType == 1 {
// Folder entry - no progress shown for folder creation
if !pathComponents.isEmpty {
@@ -166,14 +169,14 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient {
completedItemCount += 1
// Request next item if not done
- if completedItemCount < folderItemCount {
+ if completedItemCount < self.folderItemCount {
try await sendAction(socket: socket, action: .nextFile) // nextFile
}
} else if itemType == 0 {
// File entry
let parentComponents = pathComponents.dropLast()
- let fileName = pathComponents.last ?? "untitled"
+ let fileName = pathComponents.last ?? "Untitled"
// Request file download
try await sendAction(socket: socket, action: .sendFile) // sendFile
@@ -181,47 +184,38 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient {
// Read file size
let fileSizeData = try await socket.read(4)
let fileSize = fileSizeData.readUInt32(at: 0)!
- totalBytesTransferred += 4
- print("HotlineFolderDownloadClient[\(referenceNumber)]: File '\(fileName)' size: \(fileSize) bytes")
+ self.updateProgress(4)
// Notify item progress before download starts
completedItemCount += 1
itemProgressHandler?(HotlineFolderItemProgress(
fileName: fileName,
itemNumber: completedItemCount,
- totalItems: folderItemCount
+ totalItems: self.folderItemCount
))
// Download the file with overall folder progress tracking
- let (fileURL, fileBytesRead) = try await downloadFile(
+ try await self.downloadFile(
socket: socket,
fileName: fileName,
parentPath: Array(parentComponents),
destinationFolder: destinationURL,
fileSize: fileSize,
- itemNumber: completedItemCount,
- totalItems: folderItemCount,
- totalBytesTransferredSoFar: totalBytesTransferred,
progressHandler: progressHandler
)
- totalBytesTransferred += fileBytesRead
- self.transferSize = totalBytesTransferred
-
- print("HotlineFolderDownloadClient[\(referenceNumber)]: Downloaded file to \(fileURL.path)")
-
// Request next item if not done
- if completedItemCount < folderItemCount {
+ if completedItemCount < self.folderItemCount {
try await sendAction(socket: socket, action: .nextFile) // nextFile
}
} else {
// Unknown item type
- print("HotlineFolderDownloadClient[\(referenceNumber)]: Unknown item type \(itemType), skipping")
+ print("HotlineFolderDownloadClient[\(self.referenceNumber)]: Unknown item type \(itemType), skipping")
completedItemCount += 1
- if completedItemCount < folderItemCount {
+ if completedItemCount < self.folderItemCount {
try await sendAction(socket: socket, action: .nextFile) // nextFile
}
}
@@ -231,25 +225,12 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient {
// Ensure folder progress shows 100% complete
self.folderProgress?.completedUnitCount = Int64(self.transferTotal)
-
progressHandler?(.completed(url: destinationURL))
return destinationURL
}
- // MARK: - Helper Methods
-
- private func connectToTransferServer() async throws -> NetSocket {
- print("HotlineFolderDownloadClient[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)")
-
- let socket = try await NetSocket.connect(
- host: self.serverAddress,
- port: self.serverPort + 1
- )
-
- print("HotlineFolderDownloadClient[\(referenceNumber)]: Connected!")
- return socket
- }
+ // MARK: -
private func sendAction(socket: NetSocket, action: HotlineFolderAction) async throws {
let actionData = Data(endian: .big) {
@@ -284,33 +265,34 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient {
}
return (type, comps)
}
-
+
+ @discardableResult
+ private func updateProgress(_ sent: Int) -> NetSocket.FileProgress {
+ let progress = self.estimator.update(bytes: sent)
+ self.folderProgress?.completedUnitCount = Int64(progress.sent)
+ return progress
+ }
+
+ @discardableResult
private func downloadFile(
socket: NetSocket,
fileName: String,
parentPath: [String],
destinationFolder: URL,
fileSize: UInt32,
- itemNumber: Int,
- totalItems: Int,
- totalBytesTransferredSoFar: Int,
progressHandler: (@Sendable (HotlineTransferProgress) -> Void)?
- ) async throws -> (url: URL, bytesRead: Int) {
+ ) async throws -> URL {
let fm = FileManager.default
- var bytesRead = 0
+// var bytesRead = 0
// Read file header
let headerData = try await socket.read(HotlineFileHeader.DataSize)
guard let header = HotlineFileHeader(from: headerData) else {
throw HotlineTransferClientError.failedToTransfer
}
- bytesRead += HotlineFileHeader.DataSize
+ self.updateProgress(HotlineFileHeader.DataSize)
- // Update folder progress for file header
- let totalBytesNow = totalBytesTransferredSoFar + bytesRead
- self.folderProgress?.completedUnitCount = Int64(totalBytesNow)
-
- print("HotlineFolderDownloadClient[\(referenceNumber)]: File has \(header.forkCount) forks")
+ print("HotlineFolderDownloadClient[\(self.referenceNumber)]: File has \(header.forkCount) forks")
var resourceForkData: Data?
var fileHandle: FileHandle?
@@ -328,27 +310,18 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient {
guard let forkHeader = HotlineFileForkHeader(from: forkHeaderData) else {
throw HotlineTransferClientError.failedToTransfer
}
- bytesRead += HotlineFileForkHeader.DataSize
-
- // Update folder progress for fork header
- let totalBytesNow = totalBytesTransferredSoFar + bytesRead
- self.folderProgress?.completedUnitCount = Int64(totalBytesNow)
+ self.updateProgress(HotlineFileForkHeader.DataSize)
if forkHeader.isInfoFork {
- // Read INFO fork
- print("HotlineFolderDownloadClient[\(referenceNumber)]: Reading INFO fork (\(forkHeader.dataSize) bytes)")
+ // Info fork
let infoData = try await socket.read(Int(forkHeader.dataSize))
- bytesRead += infoData.count
-
- // Update folder progress for INFO fork
- let totalBytesNow = totalBytesTransferredSoFar + bytesRead
- self.folderProgress?.completedUnitCount = Int64(totalBytesNow)
+ self.updateProgress(infoData.count)
guard let info = HotlineFileInfoFork(from: infoData) else {
throw HotlineTransferClientError.failedToTransfer
}
- // Create parent folders
+ // Create parent folder
let parentFolderURL = destinationFolder.appendingPathComponents(parentPath)
if !fm.fileExists(atPath: parentFolderURL.path) {
try fm.createDirectory(at: parentFolderURL, withIntermediateDirectories: true)
@@ -364,63 +337,54 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient {
}
else if forkHeader.isDataFork {
- // Stream DATA fork to disk
- print("HotlineFolderDownloadClient[\(referenceNumber)]: Reading DATA fork (\(forkHeader.dataSize) bytes)")
-
+ // Data fork
guard let fh = fileHandle else {
throw HotlineTransferClientError.failedToTransfer
}
fileDataForkSize = Int(forkHeader.dataSize)
- // Stream data fork using NetSocket's optimized file streaming
+ // Stream data fork to disk
let updates = await socket.receiveFile(to: fh, length: fileDataForkSize)
- for try await p in updates {
- // Calculate overall folder progress
- let totalBytesNow = totalBytesTransferredSoFar + bytesRead + p.sent
- let rawProgress = self.transferTotal > 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0
- let overallProgress = min(rawProgress, 1.0) // Clamp to 1.0 to avoid exceeding 100%
-
- // Update folder-level Finder progress
- self.folderProgress?.completedUnitCount = Int64(totalBytesNow)
-
- // Calculate overall folder time estimate based on current speed
- let remainingBytes = max(0, self.transferTotal - totalBytesNow)
- let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 {
- TimeInterval(remainingBytes) / speed
- } else {
- nil
- }
-
+ for try await fileProgress in updates {
+ let progress = self.updateProgress(fileProgress.now)
+
// Report overall folder progress to UI
progressHandler?(.transfer(
name: fileName,
- size: totalBytesNow,
- total: self.transferTotal,
- progress: overallProgress,
- speed: p.bytesPerSecond,
- estimate: estimate
+ size: progress.sent,
+ total: progress.total ?? 0,
+ progress: progress.progress,
+ speed: progress.bytesPerSecond,
+ estimate: progress.estimatedTimeRemaining
))
}
- bytesRead += fileDataForkSize
} else if forkHeader.isResourceFork {
- // Read RESOURCE fork
+ // Resource fork
resourceForkData = try await socket.read(Int(forkHeader.dataSize))
- bytesRead += Int(forkHeader.dataSize)
-
- // Update folder progress for RESOURCE fork
- let totalBytesNow = totalBytesTransferredSoFar + bytesRead
- self.folderProgress?.completedUnitCount = Int64(totalBytesNow)
+ let progress = self.updateProgress(resourceForkData?.count ?? 0)
+ progressHandler?(.transfer(
+ name: fileName,
+ size: progress.sent,
+ total: progress.total ?? 0,
+ progress: progress.progress,
+ speed: progress.bytesPerSecond,
+ estimate: progress.estimatedTimeRemaining
+ ))
} else {
- // Skip unsupported fork
+ // Unsupported fork
try await socket.skip(Int(forkHeader.dataSize))
- bytesRead += Int(forkHeader.dataSize)
-
- // Update folder progress for skipped fork
- let totalBytesNow = totalBytesTransferredSoFar + bytesRead
- self.folderProgress?.completedUnitCount = Int64(totalBytesNow)
+ let progress = self.updateProgress(Int(forkHeader.dataSize))
+ progressHandler?(.transfer(
+ name: fileName,
+ size: progress.sent,
+ total: progress.total ?? 0,
+ progress: progress.progress,
+ speed: progress.bytesPerSecond,
+ estimate: progress.estimatedTimeRemaining
+ ))
}
}
@@ -428,23 +392,19 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient {
try? fileHandle?.close()
fileHandle = nil
- guard let finalPath = filePath else {
+ guard let filePath else {
throw HotlineTransferClientError.failedToTransfer
}
// Write resource fork if present
if let rsrcData = resourceForkData, !rsrcData.isEmpty {
- try writeResourceFork(data: rsrcData, to: finalPath)
+ try writeResourceFork(data: rsrcData, to: filePath)
}
- return (finalPath, bytesRead)
+ return filePath
}
private func writeResourceFork(data: Data, to url: URL) throws {
- var resolvedURL = url
- resolvedURL.resolveSymlinksInPath()
-
- let resourceURL = resolvedURL.urlForResourceFork()
- try data.write(to: resourceURL)
+ try data.write(to: url.resolvingSymlinksInPath().urlForResourceFork())
}
}
diff --git a/Hotline/Library/Extensions.swift b/Hotline/Library/Extensions.swift
index cf9f8ff..5ebc7db 100644
--- a/Hotline/Library/Extensions.swift
+++ b/Hotline/Library/Extensions.swift
@@ -147,6 +147,11 @@ extension URL {
return filePath
}
+
+ func generateUniqueFileURL(filename base: String) -> URL {
+ let filePath = self.generateUniqueFilePath(filename: base)
+ return URL(filePath: filePath)
+ }
}
// MARK: -
diff --git a/Hotline/Library/NetSocket/FileProgress.swift b/Hotline/Library/NetSocket/FileProgress.swift
index c2306f3..2064c59 100644
--- a/Hotline/Library/NetSocket/FileProgress.swift
+++ b/Hotline/Library/NetSocket/FileProgress.swift
@@ -12,14 +12,27 @@ public extension NetSocket {
public let sent: Int
/// Total file size (may be nil if unknown)
public let total: Int?
+ /// Size of most recent packet
+ public let now: Int
+ /// Total progress so far (0.0 to 1.0)
+ public let progress: Double
/// Smoothed transfer rate in bytes per second (EMA), if enough samples collected
public let bytesPerSecond: Double?
/// Estimated time remaining (seconds) based on smoothed rate, if available
public let estimatedTimeRemaining: TimeInterval?
- public init(sent: Int, total: Int?, bytesPerSecond: Double? = nil, estimatedTimeRemaining: TimeInterval? = nil) {
+ public init(sent: Int, total: Int?, now: Int = 0, bytesPerSecond: Double? = nil, estimatedTimeRemaining: TimeInterval? = nil) {
self.sent = sent
self.total = total
+ self.now = now
+
+ if let t = total {
+ self.progress = max(0.0, min(1.0, Double(sent) / Double(t)))
+ }
+ else {
+ self.progress = 0.0
+ }
+
self.bytesPerSecond = bytesPerSecond
self.estimatedTimeRemaining = estimatedTimeRemaining
}
diff --git a/Hotline/Library/NetSocket/TransferRateEstimator.swift b/Hotline/Library/NetSocket/TransferRateEstimator.swift
index badf87a..61383ff 100644
--- a/Hotline/Library/NetSocket/TransferRateEstimator.swift
+++ b/Hotline/Library/NetSocket/TransferRateEstimator.swift
@@ -70,6 +70,7 @@ public struct TransferRateEstimator {
self.minSamples = minSamples
}
+ @discardableResult
public mutating func update(total: Int) -> NetSocket.FileProgress {
return self.update(bytes: max(0, total - self.transferred))
}
@@ -80,6 +81,7 @@ public struct TransferRateEstimator {
///
/// - Parameter bytes: Number of bytes transferred in this sample
/// - Returns: Current progress with speed and ETA estimates
+ @discardableResult
public mutating func update(bytes: Int) -> NetSocket.FileProgress {
let clock = ContinuousClock()
let now = clock.now
@@ -127,6 +129,7 @@ public struct TransferRateEstimator {
return NetSocket.FileProgress(
sent: self.transferred,
total: self.total,
+ now: bytes,
bytesPerSecond: haveEstimate ? self.emaBytesPerSecond : nil,
estimatedTimeRemaining: eta
)
diff --git a/Hotline/Library/Views/FileIconView.swift b/Hotline/Library/Views/FileIconView.swift
index d0949fa..836c990 100644
--- a/Hotline/Library/Views/FileIconView.swift
+++ b/Hotline/Library/Views/FileIconView.swift
@@ -2,7 +2,7 @@ import SwiftUI
import UniformTypeIdentifiers
struct FolderIconView: View {
- private func folderIcon() -> Image {
+ private var folderIcon: Image {
#if os(iOS)
return Image(systemName: "folder.fill")
#elseif os(macOS)
@@ -11,7 +11,7 @@ struct FolderIconView: View {
}
var body: some View {
- folderIcon()
+ self.folderIcon
.resizable()
.scaledToFit()
}
@@ -22,7 +22,7 @@ struct FileIconView: View {
let fileType: String?
#if os(iOS)
- private func fileIcon() -> Image {
+ private var fileIcon: Image {
let fileExtension = (self.filename as NSString).pathExtension
if let fileType = UTType(filenameExtension: fileExtension) {
if fileType.isSubtype(of: .movie) {
@@ -45,7 +45,7 @@ struct FileIconView: View {
return Image(systemName: "doc")
}
#elseif os(macOS)
- private func fileIcon() -> Image {
+ private var fileIcon: Image {
let fileExtension = (self.filename as NSString).pathExtension
if !fileExtension.isEmpty,
@@ -60,14 +60,12 @@ struct FileIconView: View {
else {
return Image(nsImage: NSWorkspace.shared.icon(for: UTType.data))
}
-
-// Image(nsImage: NSWorkspace.shared.icon(for: UTType(filenameExtension: (filename as NSString).pathExtension) ?? UTType.content))
}
#endif
var body: some View {
- fileIcon()
+ self.fileIcon
.resizable()
.scaledToFit()
}
diff --git a/Hotline/Models/TransferInfo.swift b/Hotline/Models/TransferInfo.swift
index 10535bf..ebe9f64 100644
--- a/Hotline/Models/TransferInfo.swift
+++ b/Hotline/Models/TransferInfo.swift
@@ -7,23 +7,35 @@ class TransferInfo: Identifiable, Equatable, Hashable {
var referenceNumber: UInt32
var title: String
var size: UInt
- var progress: Double = 0.0
- var speed: Double? = nil
- var timeRemaining: TimeInterval? = nil
+
+ // Status
var completed: Bool = false
var failed: Bool = false
var cancelled: Bool = false
- var isFolder: Bool = false
-
var done: Bool {
self.completed || self.failed || self.cancelled
}
+
+ var progress: Double = 0.0
+ var speed: Double? = nil
+ var timeRemaining: TimeInterval? = nil
+
+ var isUpload: Bool = false
+ var isDownload: Bool {
+ get { !self.isUpload }
+ set { self.isUpload = !newValue }
+ }
+
+ // Folder transfers
+ var isFolder: Bool = false
+ var folderName: String? = nil
+ var fileName: String? = nil
// Server association - tracks which HotlineState this transfer belongs to
var serverID: UUID
var serverName: String?
- // For file based transfers (i.e. not previews)
+ // For file based transfers
var fileURL: URL? = nil
var progressCallback: ((TransferInfo) -> Void)? = nil
diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift
index f814818..7335f37 100644
--- a/Hotline/State/HotlineState.swift
+++ b/Hotline/State/HotlineState.swift
@@ -895,6 +895,7 @@ class HotlineState: Equatable {
serverID: self.id,
serverName: self.serverName ?? self.serverTitle
)
+ transfer.isUpload = false
transfer.downloadCallback = callback
transfer.progressCallback = progressCallback
AppState.shared.addTransfer(transfer)
@@ -980,7 +981,7 @@ class HotlineState: Equatable {
path: [String],
to destination: URL? = nil,
progress progressCallback: ((TransferInfo) -> Void)? = nil,
- itemProgress itemProgressCallback: ((TransferInfo, String, Int, Int) -> Void)? = nil,
+// itemProgress itemProgressCallback: ((TransferInfo, String, Int, Int) -> Void)? = nil,
complete callback: ((TransferInfo) -> Void)? = nil
) {
guard let client = self.client else { return }
@@ -1013,6 +1014,8 @@ class HotlineState: Equatable {
serverName: self.serverName ?? self.serverTitle
)
transfer.isFolder = true
+ transfer.folderName = folderName
+ transfer.isUpload = false
transfer.downloadCallback = callback
transfer.progressCallback = progressCallback
AppState.shared.addTransfer(transfer)
@@ -1031,8 +1034,6 @@ class HotlineState: Equatable {
guard self != nil else { return }
do {
- let folderURL: URL
-
// Download folder with progress tracking
let location: HotlineDownloadLocation = if let destination {
.url(destination)
@@ -1040,7 +1041,7 @@ class HotlineState: Equatable {
.downloads(folderName)
}
- folderURL = try await downloadClient.download(to: location, progress: { progress in
+ let folderURL = try await downloadClient.download(to: location, progress: { progress in
switch progress {
case .preparing:
break
@@ -1057,14 +1058,14 @@ class HotlineState: Equatable {
transfer.completed = true
transfer.fileURL = url
}
- }, itemProgress: { itemInfo in
- // Update transfer title with current file being downloaded
- transfer.title = "\(itemInfo.fileName) (\(itemInfo.itemNumber)/\(itemInfo.totalItems))"
- itemProgressCallback?(transfer, itemInfo.fileName, itemInfo.itemNumber, itemInfo.totalItems)
+ }, items: { item in
+ transfer.title = item.fileName
+ transfer.fileName = item.fileName
})
// Mark as completed
transfer.progress = 1.0
+ transfer.fileName = nil
transfer.title = folderName // Reset title to folder name
// Call completion callback
@@ -1174,6 +1175,7 @@ class HotlineState: Equatable {
serverName: self.serverName ?? self.serverTitle
)
transfer.isFolder = true
+ transfer.isUpload = true
transfer.uploadCallback = callback
transfer.progressCallback = progressCallback
AppState.shared.addTransfer(transfer)
@@ -1294,6 +1296,7 @@ class HotlineState: Equatable {
serverID: self.id,
serverName: self.serverName ?? self.serverTitle
)
+ transfer.isUpload = true
transfer.uploadCallback = callback
AppState.shared.addTransfer(transfer)
diff --git a/Hotline/macOS/TransfersView.swift b/Hotline/macOS/TransfersView.swift
index bf8c8bd..5f9357d 100644
--- a/Hotline/macOS/TransfersView.swift
+++ b/Hotline/macOS/TransfersView.swift
@@ -70,58 +70,70 @@ struct TransfersView: View {
.listStyle(.inset)
.environment(\.defaultMinListRowHeight, 56)
.contextMenu(forSelectionType: TransferInfo.self) { items in
- if items.allSatisfy(\.completed) {
- let fileURLs: [URL] = items.compactMap(\.fileURL)
-
- Button("Remove Transfer\(items.count > 1 ? "s" : "")", systemImage: "xmark") {
- self.appState.cancelTransfers(ids: items.map(\.id))
- self.selectedTransfers = []
- }
-
- Divider()
-
- Button("Open", systemImage: "arrow.up.right.square") {
- for fileURL in fileURLs {
- NSWorkspace.shared.open(fileURL)
- }
- }
-
- self.openWithMenu(for: fileURLs)
-
- Button("Show in Finder", systemImage: "finder") {
- NSWorkspace.shared.activateFileViewerSelecting(fileURLs)
+ self.contextMenuForItems(items)
+ } primaryAction: { items in
+ self.performPrimaryAction(for: items)
+ }
+ }
+
+ // MARK: - Double Click
+
+ private func performPrimaryAction(for items: Set<TransferInfo>) {
+ if let fileURL = items.first?.fileURL {
+ NSWorkspace.shared.open(fileURL)
+ }
+ }
+
+ // MARK: - Context Menu
+
+ @ViewBuilder
+ private func contextMenuForItems(_ items: Set<TransferInfo>) -> some View {
+ if items.allSatisfy(\.completed) {
+ let fileURLs: [URL] = items.compactMap(\.fileURL)
+
+ Button("Remove Transfer\(items.count > 1 ? "s" : "")", systemImage: "xmark") {
+ self.appState.cancelTransfers(ids: items.map(\.id))
+ self.selectedTransfers = []
+ }
+
+ Divider()
+
+ Button("Open", systemImage: "arrow.up.right.square") {
+ for fileURL in fileURLs {
+ NSWorkspace.shared.open(fileURL)
}
+ }
+
+ self.openWithMenu(for: fileURLs)
+
+ Button("Show in Finder", systemImage: "finder") {
+ NSWorkspace.shared.activateFileViewerSelecting(fileURLs)
+ }
+
+ Divider()
+
+ Button("Move to Trash", systemImage: "trash") {
+ self.appState.cancelTransfers(ids: items.map(\.id))
+ NSWorkspace.shared.recycle(fileURLs)
+ self.selectedTransfers = []
+ }
+ } else {
+ Button("Remove Transfer\(items.count > 1 ? "s" : "")", systemImage: "xmark") {
+ self.appState.cancelTransfers(ids: items.map(\.id))
+ self.selectedTransfers = []
+ }
+
+ Divider()
+
+ Button("Move to Trash", systemImage: "trash") {
+ self.appState.cancelTransfers(ids: items.map(\.id))
- Divider()
-
- Button("Move to Trash", systemImage: "trash") {
- self.appState.cancelTransfers(ids: items.map(\.id))
+ let fileURLs: [URL] = items.compactMap(\.fileURL)
+ if !fileURLs.isEmpty {
NSWorkspace.shared.recycle(fileURLs)
- self.selectedTransfers = []
}
- }
- else {
- Button("Remove Transfer\(items.count > 1 ? "s" : "")", systemImage: "xmark") {
- self.appState.cancelTransfers(ids: items.map(\.id))
- self.selectedTransfers = []
- }
-
- Divider()
- Button("Move to Trash", systemImage: "trash") {
- self.appState.cancelTransfers(ids: items.map(\.id))
-
- let fileURLs: [URL] = items.compactMap(\.fileURL)
- if !fileURLs.isEmpty {
- NSWorkspace.shared.recycle(fileURLs)
- }
-
- self.selectedTransfers = []
- }
- }
- } primaryAction: { items in
- if let fileURL = items.first?.fileURL {
- NSWorkspace.shared.open(fileURL)
+ self.selectedTransfers = []
}
}
}
@@ -250,6 +262,7 @@ struct TransfersView: View {
}
}
}
+
}
// MARK: - Transfer Row
@@ -259,6 +272,56 @@ struct TransferRow: View {
@Bindable var transfer: TransferInfo
+ var body: some View {
+ HStack(alignment: .center, spacing: 8) {
+ if self.transfer.isFolder {
+ self.folderIconView
+ }
+ else {
+ self.fileIconView
+ }
+
+ VStack(alignment: .leading, spacing: 2) {
+ HStack(alignment: .firstTextBaseline, spacing: 8) {
+ Text(self.transfer.title)
+ .font(.headline)
+ .lineLimit(1)
+ .truncationMode(.tail)
+
+ Spacer()
+
+ if !self.transfer.done {
+ self.statsView
+ }
+ }
+
+ // Progress bar and status
+ if self.transfer.cancelled {
+ Text("Cancelled")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ else if self.transfer.failed {
+ Text("Failed")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ else if self.transfer.completed {
+ Text("Downloaded")
+ .font(.subheadline)
+ .foregroundStyle(.fileComplete)
+ }
+ else {
+ ProgressView(value: self.transfer.progress, total: 1.0)
+ .progressViewStyle(.linear)
+ .controlSize(.large)
+ }
+ }
+ }
+ }
+
+ // MARK: -
+
private var statsView: some View {
HStack(spacing: 8) {
// Progress percentage
@@ -266,28 +329,24 @@ struct TransferRow: View {
// Speed
if let speed = self.transfer.speed {
- // TODO: Use arrow.up for uploads.
- Label(self.formatSpeed(speed), systemImage: "arrow.down")
-// Text(self.formatSpeed(speed))
+ Label(self.formatSpeed(speed), systemImage: self.transfer.isUpload ? "arrow.up" : "arrow.down")
}
// Time remaining
if let timeRemaining = self.transfer.timeRemaining {
Label(self.formatTimeRemaining(timeRemaining), systemImage: "clock")
-// Text(self.formatTimeRemaining(timeRemaining))
}
// File size
Label(self.formatSize(self.transfer.size), systemImage: "document")
-// Text(self.formatSize(self.transfer.size))
}
.font(.subheadline)
.foregroundStyle(.secondary)
.monospacedDigit()
}
- private var fileIconView: some View {
- FileIconView(filename: self.transfer.title, fileType: nil)
+ private var folderIconView: some View {
+ FolderIconView()
.frame(width: 32, height: 32)
.overlay(alignment: .bottomTrailing) {
if self.transfer.cancelled || self.transfer.failed {
@@ -305,50 +364,33 @@ struct TransferRow: View {
.scaledToFit()
.frame(width: 16, height: 16)
}
+ else {
+ FileIconView(filename: self.transfer.title, fileType: nil)
+ .frame(width: 16, height: 16)
+ }
}
}
- var body: some View {
- HStack(alignment: .center, spacing: 8) {
- self.fileIconView
-
- VStack(alignment: .leading, spacing: 2) {
- HStack(alignment: .firstTextBaseline, spacing: 8) {
- Text(self.transfer.title)
- .font(.headline)
- .lineLimit(1)
- .truncationMode(.tail)
-
- Spacer()
-
- if !self.transfer.done {
- self.statsView
- }
- }
-
- // Progress bar and status
- if self.transfer.cancelled {
- Text("Cancelled")
- .font(.subheadline)
- .foregroundStyle(.secondary)
- }
- else if self.transfer.failed {
- Text("Failed")
- .font(.subheadline)
- .foregroundStyle(.secondary)
+ private var fileIconView: some View {
+ FileIconView(filename: self.transfer.title, fileType: nil)
+ .frame(width: 32, height: 32)
+ .overlay(alignment: .bottomTrailing) {
+ if self.transfer.cancelled || self.transfer.failed {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .resizable()
+ .symbolRenderingMode(.multicolor)
+ .scaledToFit()
+ .frame(width: 16, height: 16)
}
else if self.transfer.completed {
- Text("Downloaded")
- .font(.subheadline)
- .foregroundStyle(.fileComplete)
- }
- else {
- ProgressView(value: self.transfer.progress, total: 1.0)
- .progressViewStyle(.linear)
- .controlSize(.large)
+ Image(systemName: "checkmark.circle.fill")
+ .resizable()
+ .symbolRenderingMode(.palette)
+ .foregroundStyle(.white, .fileComplete)
+ .scaledToFit()
+ .frame(width: 16, height: 16)
}
}
- }
}
// MARK: - Formatting