From a55318fa8d643160900bec3e6b14e7404c63497a Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 7 Nov 2025 12:44:55 -0800 Subject: Organize Library folder a bit. Update Tracker add/edit sheet design. --- Hotline/Library/Views/AsyncLinkPreview.swift | 57 +++++++++++ Hotline/Library/Views/BetterTextEditor.swift | 119 +++++++++++++++++++++++ Hotline/Library/Views/FileIconView.swift | 74 ++++++++++++++ Hotline/Library/Views/FileImageView.swift | 98 +++++++++++++++++++ Hotline/Library/Views/GroupedIconView.swift | 23 +++++ Hotline/Library/Views/HotlinePanel.swift | 64 ++++++++++++ Hotline/Library/Views/QuickLookPreviewView.swift | 22 +++++ Hotline/Library/Views/SpinningGlobeView.swift | 90 +++++++++++++++++ Hotline/Library/Views/VisualEffectView.swift | 22 +++++ 9 files changed, 569 insertions(+) create mode 100644 Hotline/Library/Views/AsyncLinkPreview.swift create mode 100644 Hotline/Library/Views/BetterTextEditor.swift create mode 100644 Hotline/Library/Views/FileIconView.swift create mode 100644 Hotline/Library/Views/FileImageView.swift create mode 100644 Hotline/Library/Views/GroupedIconView.swift create mode 100644 Hotline/Library/Views/HotlinePanel.swift create mode 100644 Hotline/Library/Views/QuickLookPreviewView.swift create mode 100644 Hotline/Library/Views/SpinningGlobeView.swift create mode 100644 Hotline/Library/Views/VisualEffectView.swift (limited to 'Hotline/Library/Views') diff --git a/Hotline/Library/Views/AsyncLinkPreview.swift b/Hotline/Library/Views/AsyncLinkPreview.swift new file mode 100644 index 0000000..89b186f --- /dev/null +++ b/Hotline/Library/Views/AsyncLinkPreview.swift @@ -0,0 +1,57 @@ +import SwiftUI +import LinkPresentation + +fileprivate class CustomLinkView: LPLinkView { + override var intrinsicContentSize: CGSize { CGSize(width: 0, height: super.intrinsicContentSize.height) } +} + +struct AsyncLinkPreview: View { + @State private var metadata: LPLinkMetadata? + @State private var isLoading = true + let url: URL? + + func fetchMetadata() async { + guard let url else { + self.isLoading = false + return + } + do { + let provider = LPMetadataProvider() + let metadata = try await provider.startFetchingMetadata(for: url) + self.metadata = metadata + self.isLoading = false + } catch { + self.isLoading = false + } + } + + var body: some View { + if isLoading { + ProgressView() + .controlSize(.small) + .task { + await self.fetchMetadata() + } + } else if let metadata = metadata { + LinkView(metadata: metadata) + .frame(width: 200) + } else { + Text(LocalizedStringKey(url!.absoluteString)) + .multilineTextAlignment(.leading) + .tint(Color("Link Color")) + } + } +} + +struct LinkView: NSViewRepresentable { + var metadata: LPLinkMetadata + + func makeNSView(context: Context) -> LPLinkView { + let linkView = CustomLinkView(metadata: metadata) + return linkView + } + + func updateNSView(_ nsView: LPLinkView, context: Context) { + // Nothing required + } +} diff --git a/Hotline/Library/Views/BetterTextEditor.swift b/Hotline/Library/Views/BetterTextEditor.swift new file mode 100644 index 0000000..47746e2 --- /dev/null +++ b/Hotline/Library/Views/BetterTextEditor.swift @@ -0,0 +1,119 @@ +import SwiftUI + +struct BetterTextEditor: NSViewRepresentable { + + @Environment(\.lineSpacing) private var lineSpacing + + @Binding private var text: String + + private var customizations: [(NSTextView) -> Void] = [] + + init(text: Binding) { + self._text = text + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeNSView(context: Context) -> NSScrollView { + let scrollview = NSTextView.scrollablePlainDocumentContentTextView() + let textview = scrollview.documentView as! NSTextView + + textview.string = self.text + textview.delegate = context.coordinator + +// let p = NSMutableParagraphStyle() +// p.lineSpacing = self.lineSpacing +//// textview.defaultParagraphStyle = p +// textview.typingAttributes = [ +// .paragraphStyle: p +// ] + + textview.isEditable = true + textview.isRichText = false + textview.allowsUndo = true + textview.isFieldEditor = false + textview.usesAdaptiveColorMappingForDarkAppearance = true + textview.drawsBackground = false // true + textview.usesRuler = false + textview.usesFindBar = false + textview.isIncrementalSearchingEnabled = false + textview.isAutomaticQuoteSubstitutionEnabled = false + textview.isAutomaticDashSubstitutionEnabled = false + textview.isAutomaticSpellingCorrectionEnabled = true + textview.isAutomaticDataDetectionEnabled = false + textview.isAutomaticLinkDetectionEnabled = false + textview.usesInspectorBar = false + textview.usesFontPanel = false + textview.importsGraphics = false + textview.allowsImageEditing = false + textview.displaysLinkToolTips = true + textview.backgroundColor = NSColor.textBackgroundColor + textview.textContainerInset = NSSize(width: 16, height: 16) + textview.isContinuousSpellCheckingEnabled = true + textview.setSelectedRange(NSMakeRange(0, 0)) + self.customizations.forEach { $0(textview) } + + scrollview.scrollerStyle = .overlay + + return scrollview + } + + func updateNSView(_ nsView: NSScrollView, context: Context) { + let textview = nsView.documentView as! NSTextView + + if textview.string != text { + textview.string = text + } + + self.customizations.forEach { $0(textview) } + } + + func betterEditorFont(_ font: NSFont) -> Self { + self.customized { $0.font = font } + } + + func betterEditorParagraphStyle(_ paragraphStyle: NSParagraphStyle) -> Self { + self.customized { $0.defaultParagraphStyle = paragraphStyle } + } + + func betterEditorAutomaticDashSubstitution(_ enabled: Bool) -> Self { + self.customized { $0.isAutomaticDashSubstitutionEnabled = enabled } + } + + func betterEditorAutomaticQuoteSubstitution(_ enabled: Bool) -> Self { + self.customized { $0.isAutomaticQuoteSubstitutionEnabled = enabled } + } + + func betterEditorAutomaticSpellingCorrection(_ enabled: Bool) -> Self { + self.customized { $0.isAutomaticSpellingCorrectionEnabled = enabled } + } + + func betterEditorTextInset(_ size: NSSize) -> Self { + self.customized { $0.textContainerInset = size } + } + + class Coordinator: NSObject, NSTextViewDelegate { + var parent: BetterTextEditor + + init(_ parent: BetterTextEditor) { + self.parent = parent + } + + func textDidChange(_ notification: Notification) { + guard let textview = notification.object as? NSTextView else { + return + } + self.parent.text = textview.string + } + } +} + +private extension BetterTextEditor { + private func customized(_ customization: @escaping (NSTextView) -> Void) -> Self { + var copy = self + copy.customizations.append(customization) + return copy + } +} diff --git a/Hotline/Library/Views/FileIconView.swift b/Hotline/Library/Views/FileIconView.swift new file mode 100644 index 0000000..d0949fa --- /dev/null +++ b/Hotline/Library/Views/FileIconView.swift @@ -0,0 +1,74 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct FolderIconView: View { + private func folderIcon() -> Image { +#if os(iOS) + return Image(systemName: "folder.fill") +#elseif os(macOS) + return Image(nsImage: NSWorkspace.shared.icon(for: UTType.folder)) +#endif + } + + var body: some View { + folderIcon() + .resizable() + .scaledToFit() + } +} + +struct FileIconView: View { + let filename: String + let fileType: String? + + #if os(iOS) + private func fileIcon() -> Image { + let fileExtension = (self.filename as NSString).pathExtension + if let fileType = UTType(filenameExtension: fileExtension) { + if fileType.isSubtype(of: .movie) { + return Image(systemName: "play.rectangle") + } + else if fileType.isSubtype(of: .image) { + return Image(systemName: "photo") + } + else if fileType.isSubtype(of: .archive) { + return Image(systemName: "doc.zipper") + } + else if fileType.isSubtype(of: .text) { + return Image(systemName: "doc.text") + } + else { + return Image(systemName: "doc") + } + } + + return Image(systemName: "doc") + } + #elseif os(macOS) + private func fileIcon() -> Image { + let fileExtension = (self.filename as NSString).pathExtension + + if !fileExtension.isEmpty, + let uttype = UTType(filenameExtension: fileExtension) { + return Image(nsImage: NSWorkspace.shared.icon(for: uttype)) + } + else if let fileType = self.fileType, + let fileTypeExtension = FileManager.HFSTypeToExtension[fileType.lowercased()], + let uttype = UTType(filenameExtension: fileTypeExtension) { + return Image(nsImage: NSWorkspace.shared.icon(for: uttype)) + } + 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() + .resizable() + .scaledToFit() + } +} diff --git a/Hotline/Library/Views/FileImageView.swift b/Hotline/Library/Views/FileImageView.swift new file mode 100644 index 0000000..0cc50f1 --- /dev/null +++ b/Hotline/Library/Views/FileImageView.swift @@ -0,0 +1,98 @@ +import SwiftUI + +struct FileImageView: NSViewRepresentable { + var image: NSImage? + + let minimumSize: CGSize = CGSize(width: 350, height: 350) + let presentationPaddingRatio: Double = 0.5 + + func makeNSView(context: Context) -> NSImageView { + let imageView = NSImageView() + imageView.imageScaling = .scaleProportionallyUpOrDown + imageView.animates = true + imageView.isEditable = false + imageView.allowsCutCopyPaste = true + imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + if let img = self.image { + imageView.image = img + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.resizeWindowForImageView(imageView) + } + } + + return imageView + } + + func updateNSView(_ nsView: NSImageView, context: Context) { + nsView.image = self.image + } + + // MARK: - + + func resizeWindowForImageView(_ imageView: NSImageView) { + guard let window = imageView.window, let img = imageView.image else { + return + } + + var windowRect = window.contentLayoutRect + let windowChromeSize = CGSize(width: window.frame.width - windowRect.width, height: window.frame.height - windowRect.height) + + var windowMinSize: CGSize = windowRect.size + let centerPoint = CGPoint(x: window.frame.midX, y: window.frame.midY) + + windowRect.size = img.size + + if let screen = window.screen { + var paddedScreenSize = screen.frame.size + paddedScreenSize.width *= self.presentationPaddingRatio + paddedScreenSize.height *= self.presentationPaddingRatio + + windowMinSize = aspectFit(source: windowRect.size, bounds: self.minimumSize) + windowRect.size = aspectFit(source: windowRect.size, bounds: paddedScreenSize, minimum: windowMinSize) + } + + windowRect.size.width += windowChromeSize.width + windowRect.size.height += windowChromeSize.height + + windowRect.origin.x = centerPoint.x - windowRect.width / 2.0 + windowRect.origin.y = centerPoint.y - windowRect.height / 2.0 + + window.setFrame(windowRect, display: true, animate: true) + +// Do these APIs even work?? +// window.aspectRatio = windowRect.size +// window.contentAspectRatio = windowRect.size + } + + func aspectFit(source sourceSize: CGSize, bounds boundingSize: CGSize, minimum minSize: CGSize? = nil) -> CGSize { + let sourceAspectRatio = sourceSize.width / sourceSize.height + + var fitSize: CGSize = sourceSize + + if fitSize.width > boundingSize.width { + fitSize.width = boundingSize.width + fitSize.height = fitSize.width / sourceAspectRatio + } + + if fitSize.height > boundingSize.height { + fitSize.height = boundingSize.height + fitSize.width = fitSize.height * sourceAspectRatio + } + + if let m = minSize { + if fitSize.width < m.width { + fitSize.width = m.width + fitSize.height = fitSize.width / sourceAspectRatio + } + + if fitSize.height < m.height { + fitSize.height = m.height + fitSize.width = fitSize.height * sourceAspectRatio + } + } + + return fitSize + } +} diff --git a/Hotline/Library/Views/GroupedIconView.swift b/Hotline/Library/Views/GroupedIconView.swift new file mode 100644 index 0000000..313fac6 --- /dev/null +++ b/Hotline/Library/Views/GroupedIconView.swift @@ -0,0 +1,23 @@ +import SwiftUI + +struct GroupedIconView: View { + let color: Color + let systemName: String + var padding: CGFloat = 6.0 + var cornerRadius: CGFloat = 8.0 + + var body: some View { + RoundedRectangle(cornerRadius: self.cornerRadius, style: .continuous) + .fill(LinearGradient(colors: [self.color.mix(with: .white, by: 0.2), self.color], startPoint: .top, endPoint: .bottom)) + .shadow(color: self.color.mix(with: .black, by: 0.5).opacity(0.2), radius: 1, x: 0, y: 1) + .overlay { + Image(systemName: self.systemName) + .resizable() + .scaledToFit() + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .white.opacity(0.4)) + .padding(self.padding) + .shadow(color: self.color.mix(with: .black, by: 0.5).opacity(0.2), radius: 0, x: 0, y: -1) + } + } +} diff --git a/Hotline/Library/Views/HotlinePanel.swift b/Hotline/Library/Views/HotlinePanel.swift new file mode 100644 index 0000000..d7d8284 --- /dev/null +++ b/Hotline/Library/Views/HotlinePanel.swift @@ -0,0 +1,64 @@ +import Cocoa +import SwiftUI + +fileprivate let HOTLINE_PANEL_SIZE: CGSize = CGSizeMake(468, 114 - 10) + +class HotlinePanel: NSPanel { + init(_ view: HotlinePanelView) { + super.init(contentRect: NSRect(x: 0, y: 0, width: HOTLINE_PANEL_SIZE.width, height: HOTLINE_PANEL_SIZE.height), styleMask: [.nonactivatingPanel, .titled, .closable, .utilityWindow, .fullSizeContentView], backing: .buffered, defer: false) + + // Make sure that the panel is in front of almost all other windows + self.isFloatingPanel = true + self.level = .floating + self.hidesOnDeactivate = true + self.animationBehavior = .utilityWindow + + // Allow the panelto appear in a fullscreen space +// self.collectionBehavior.insert(.fullScreenAuxiliary) + self.collectionBehavior.insert(.canJoinAllSpaces) + self.collectionBehavior.insert(.ignoresCycle) + +// self.appearance = NSAppearance(named: .vibrantDark) + + // Don't delete panel state when it's closed. + self.isReleasedWhenClosed = false + + self.standardWindowButton(.closeButton)?.isHidden = false + self.standardWindowButton(.zoomButton)?.isHidden = true + self.standardWindowButton(.miniaturizeButton)?.isHidden = true + + // Make it transparent, the view inside will have to set the background. + // This is necessary because otherwise, we will have some space for the titlebar on top of the height of the view itself which we don't want. + self.isOpaque = false + self.backgroundColor = .clear + + // Since we don't show a statusbar, this allows us to drag the window by its background instead of the titlebar. + self.isMovableByWindowBackground = true + self.titlebarAppearsTransparent = true + + let hostingView = NSHostingView(rootView: view.edgesIgnoringSafeArea(.top)) + hostingView.sizingOptions = [.preferredContentSize] + + let visualEffectView = NSVisualEffectView(frame: NSRect(x: 0, y: 0, width: HOTLINE_PANEL_SIZE.width, height: HOTLINE_PANEL_SIZE.height)) + visualEffectView.material = .sidebar + visualEffectView.blendingMode = .behindWindow + visualEffectView.state = NSVisualEffectView.State.active + visualEffectView.autoresizingMask = [.width, .height] + visualEffectView.autoresizesSubviews = true + visualEffectView.addSubview(hostingView) + + self.contentView = visualEffectView + + hostingView.frame = visualEffectView.bounds + + self.cascadeTopLeft(from: NSMakePoint(16, 16)) + } + + override var canBecomeKey: Bool { + return false + } + + override var canBecomeMain: Bool { + return false + } +} diff --git a/Hotline/Library/Views/QuickLookPreviewView.swift b/Hotline/Library/Views/QuickLookPreviewView.swift new file mode 100644 index 0000000..6ba154e --- /dev/null +++ b/Hotline/Library/Views/QuickLookPreviewView.swift @@ -0,0 +1,22 @@ +import SwiftUI +import Quartz + +/// Embeddable QuickLook preview view for macOS +/// +/// This view uses QLPreviewView to display file previews inline, without showing a modal. +/// Supports all file types that QuickLook supports (images, PDFs, videos, documents, etc.) +struct QuickLookPreviewView: NSViewRepresentable { + let fileURL: URL + + func makeNSView(context: Context) -> QLPreviewView { + let preview = QLPreviewView(frame: .zero, style: .normal)! + preview.autostarts = true + preview.shouldCloseWithWindow = true + preview.previewItem = fileURL as QLPreviewItem + return preview + } + + func updateNSView(_ nsView: QLPreviewView, context: Context) { + nsView.previewItem = fileURL as QLPreviewItem + } +} diff --git a/Hotline/Library/Views/SpinningGlobeView.swift b/Hotline/Library/Views/SpinningGlobeView.swift new file mode 100644 index 0000000..bccb969 --- /dev/null +++ b/Hotline/Library/Views/SpinningGlobeView.swift @@ -0,0 +1,90 @@ +import SwiftUI + +/// An animated globe icon that cycles through different world regions +/// +/// Displays a spinning globe effect by cycling through SF Symbol images showing +/// different parts of the world. Useful for indicating network activity or global content. +/// +/// Example: +/// ```swift +/// SpinningGlobeView() +/// .frame(width: 16, height: 16) +/// +/// SpinningGlobeView(frameDelay: 0.5) +/// .frame(width: 24, height: 24) +/// ``` +struct SpinningGlobeView: View { + /// Delay between frames in seconds (default: 0.3) + let frameDelay: TimeInterval + + /// SF Symbol names for each frame of the globe animation + private let globeFrames = [ + "globe.americas.fill", + "globe.europe.africa.fill", + "globe.central.south.asia.fill", + "globe.asia.australia.fill" + ] + + @State private var currentFrameIndex = 0 + @State private var animationTask: Task? + + init(frameDelay: TimeInterval = 0.25) { + self.frameDelay = frameDelay + } + + var body: some View { + Image(systemName: globeFrames[currentFrameIndex]) + .onAppear { + startAnimation() + } + .onDisappear { + stopAnimation() + } + } + + private func startAnimation() { + animationTask = Task { + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(frameDelay * 1_000_000_000)) + + guard !Task.isCancelled else { break } + + currentFrameIndex = (currentFrameIndex + 1) % globeFrames.count + } + } + } + + private func stopAnimation() { + animationTask?.cancel() + animationTask = nil + } +} + +#Preview { + VStack(spacing: 20) { + HStack(spacing: 20) { + SpinningGlobeView() + .frame(width: 12, height: 12) + + SpinningGlobeView() + .frame(width: 16, height: 16) + + SpinningGlobeView() + .frame(width: 24, height: 24) + } + + Text("Different frame delays:") + + HStack(spacing: 20) { + SpinningGlobeView(frameDelay: 0.1) + .frame(width: 16, height: 16) + + SpinningGlobeView(frameDelay: 0.3) + .frame(width: 16, height: 16) + + SpinningGlobeView(frameDelay: 0.6) + .frame(width: 16, height: 16) + } + } + .padding() +} diff --git a/Hotline/Library/Views/VisualEffectView.swift b/Hotline/Library/Views/VisualEffectView.swift new file mode 100644 index 0000000..e595c55 --- /dev/null +++ b/Hotline/Library/Views/VisualEffectView.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct VisualEffectView: NSViewRepresentable +{ + let material: NSVisualEffectView.Material + let blendingMode: NSVisualEffectView.BlendingMode + + func makeNSView(context: Context) -> NSVisualEffectView + { + let visualEffectView = NSVisualEffectView() + visualEffectView.material = material + visualEffectView.blendingMode = blendingMode + visualEffectView.state = NSVisualEffectView.State.active + return visualEffectView + } + + func updateNSView(_ visualEffectView: NSVisualEffectView, context: Context) + { + visualEffectView.material = material + visualEffectView.blendingMode = blendingMode + } +} -- cgit From f87ff05d17cedf21845abbb8d750759ba725a263 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Tue, 11 Nov 2025 15:39:02 -0800 Subject: Folder download logic cleanup. Display folder icon on folder download transfers with small icon for the current file. Fix progress for folder downloads. --- .../Transfers/HotlineFolderDownloadClient.swift | 202 ++++++++----------- Hotline/Library/Extensions.swift | 5 + Hotline/Library/NetSocket/FileProgress.swift | 15 +- .../Library/NetSocket/TransferRateEstimator.swift | 3 + Hotline/Library/Views/FileIconView.swift | 12 +- Hotline/Models/TransferInfo.swift | 24 ++- Hotline/State/HotlineState.swift | 19 +- Hotline/macOS/TransfersView.swift | 224 ++++++++++++--------- 8 files changed, 270 insertions(+), 234 deletions(-) (limited to 'Hotline/Library/Views') 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? 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 { @@ -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) { + if let fileURL = items.first?.fileURL { + NSWorkspace.shared.open(fileURL) + } + } + + // MARK: - Context Menu + + @ViewBuilder + private func contextMenuForItems(_ items: Set) -> 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 -- cgit From 5a3d06c527cf03d68149449965fade0dd5ea0ed2 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Tue, 11 Nov 2025 20:25:40 -0800 Subject: Fix warning in console about panel not restoring. Add popover for compact transfers list items. Remove push notification entitlement. --- Hotline/Hotline.entitlements | 4 ---- .../Transfers/HotlineFileDownloadClient.swift | 4 +--- Hotline/Info.plist | 10 ++++---- Hotline/Library/Views/HotlinePanel.swift | 5 +++- Hotline/macOS/ServerView.swift | 28 +++++++++++++++++++++- 5 files changed, 38 insertions(+), 13 deletions(-) (limited to 'Hotline/Library/Views') diff --git a/Hotline/Hotline.entitlements b/Hotline/Hotline.entitlements index e15d27d..074bc56 100644 --- a/Hotline/Hotline.entitlements +++ b/Hotline/Hotline.entitlements @@ -2,10 +2,6 @@ - aps-environment - development - com.apple.developer.aps-environment - development com.apple.developer.icloud-container-identifiers iCloud.co.goodmake.hotline diff --git a/Hotline/Hotline/Transfers/HotlineFileDownloadClient.swift b/Hotline/Hotline/Transfers/HotlineFileDownloadClient.swift index 82f61d4..6441047 100644 --- a/Hotline/Hotline/Transfers/HotlineFileDownloadClient.swift +++ b/Hotline/Hotline/Transfers/HotlineFileDownloadClient.swift @@ -125,9 +125,7 @@ public class HotlineFileDownloadClient: @MainActor HotlineTransferClient { destinationURL = url.resolvingSymlinksInPath() destinationFilename = destinationURL.lastPathComponent case .downloads(let filename): - var downloadsURL = fm.urls(for: .downloadsDirectory, in: .userDomainMask)[0] - downloadsURL = downloadsURL.resolvingSymlinksInPath() - destinationURL = URL(filePath: downloadsURL.generateUniqueFilePath(filename: filename)) + destinationURL = URL.downloadsDirectory.resolvingSymlinksInPath().generateUniqueFileURL(filename: filename) destinationFilename = destinationURL.lastPathComponent } diff --git a/Hotline/Info.plist b/Hotline/Info.plist index 82ee6ac..59b1e50 100644 --- a/Hotline/Info.plist +++ b/Hotline/Info.plist @@ -7,11 +7,13 @@ CFBundleTypeName Hotline Bookmark + CFBundleTypeRole + Editor LSHandlerRank Owner LSItemContentTypes - 'HTbm' + 'HTbm' @@ -19,7 +21,7 @@ CFBundleTypeRole - Viewer + Editor CFBundleURLName co.goodmake.hotline CFBundleURLSchemes @@ -29,7 +31,7 @@ CFBundleTypeRole - Viewer + Editor CFBundleURLName co.goodmake.hotline CFBundleURLSchemes @@ -46,7 +48,7 @@ UTTypeIconFiles UTTypeIdentifier - 'HTbm' + 'HTbm' UTTypeTagSpecification public.filename-extension diff --git a/Hotline/Library/Views/HotlinePanel.swift b/Hotline/Library/Views/HotlinePanel.swift index d7d8284..f6d76fc 100644 --- a/Hotline/Library/Views/HotlinePanel.swift +++ b/Hotline/Library/Views/HotlinePanel.swift @@ -22,7 +22,10 @@ class HotlinePanel: NSPanel { // Don't delete panel state when it's closed. self.isReleasedWhenClosed = false - + + // Disable state restoration for this utility panel + self.isRestorable = false + self.standardWindowButton(.closeButton)?.isHidden = false self.standardWindowButton(.zoomButton)?.isHidden = true self.standardWindowButton(.miniaturizeButton)?.isHidden = true diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 98ab976..399deba 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -425,6 +425,7 @@ struct ServerTransferRow: View { @Environment(HotlineState.self) private var model: HotlineState @State private var hovered: Bool = false @State private var buttonHovered: Bool = false + @State private var detailsShown: Bool = false var body: some View { HStack(alignment: .center, spacing: 5) { @@ -508,6 +509,7 @@ struct ServerTransferRow: View { withAnimation(.snappy(duration: 0.25, extraBounce: 0.3)) { self.hovered = hovered } + self.detailsShown = hovered } .onTapGesture(count: 2) { guard transfer.completed, let url = transfer.fileURL else { @@ -516,7 +518,31 @@ struct ServerTransferRow: View { NSWorkspace.shared.activateFileViewerSelecting([url]) } - .help(self.formattedProgressHelp) + .popover(isPresented: .constant(self.detailsShown && !self.transfer.done), arrowEdge: .trailing) { + let rows: [(String, String)] = [ + ("document", self.transfer.title), + ("info", self.transfer.displaySize), + (self.transfer.isUpload ? "arrow.up" : "arrow.down", self.transfer.displaySpeed ?? "--"), + ("clock", self.transfer.displayTimeRemaining ?? "--") + ] + + Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) { + ForEach(rows, id: \.0) { imageName, label in + GridRow { + Image(systemName: imageName) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + .gridColumnAlignment(.trailing) + Text(label) + .monospacedDigit() + .gridColumnAlignment(.leading) + } + } + } + .frame(minWidth: 200, maxWidth: 350, alignment: .leading) + .padding() + } } private var formattedProgressHelp: String { -- cgit