diff options
| -rw-r--r-- | Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift | 202 | ||||
| -rw-r--r-- | Hotline/Library/Extensions.swift | 5 | ||||
| -rw-r--r-- | Hotline/Library/NetSocket/FileProgress.swift | 15 | ||||
| -rw-r--r-- | Hotline/Library/NetSocket/TransferRateEstimator.swift | 3 | ||||
| -rw-r--r-- | Hotline/Library/Views/FileIconView.swift | 12 | ||||
| -rw-r--r-- | Hotline/Models/TransferInfo.swift | 24 | ||||
| -rw-r--r-- | Hotline/State/HotlineState.swift | 19 | ||||
| -rw-r--r-- | Hotline/macOS/TransfersView.swift | 224 |
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 |