aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Hotline/State/AppState.swift8
-rw-r--r--Hotline/macOS/TransfersView.swift303
2 files changed, 219 insertions, 92 deletions
diff --git a/Hotline/State/AppState.swift b/Hotline/State/AppState.swift
index 2d7820b..e268ba2 100644
--- a/Hotline/State/AppState.swift
+++ b/Hotline/State/AppState.swift
@@ -57,6 +57,14 @@ final class AppState {
// Remove from transfers list
self.transfers.remove(at: transferIndex)
}
+
+ /// Cancel specified transfers
+ @MainActor
+ func cancelTransfers(ids: [UUID]) {
+ for transferID in ids {
+ self.cancelTransfer(id: transferID)
+ }
+ }
/// Cancel all active transfers
@MainActor
diff --git a/Hotline/macOS/TransfersView.swift b/Hotline/macOS/TransfersView.swift
index 6b7a21b..489a50b 100644
--- a/Hotline/macOS/TransfersView.swift
+++ b/Hotline/macOS/TransfersView.swift
@@ -16,15 +16,24 @@ struct TransfersView: View {
.frame(minWidth: 500, minHeight: 200)
.navigationTitle("Transfers")
.toolbar {
-// ToolbarItem(placement: .primaryAction) {
-// Button {
-// self.appState.sweepTransfers()
-// self.selectedTransfers = []
-// } label: {
-// Label("Remove Completed", systemImage: "checklist")
-// }
-// .disabled(self.appState.transfers.isEmpty)
-// }
+ ToolbarItem(placement: .primaryAction) {
+ Button {
+ if self.selectedTransfers.isEmpty {
+ if let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first {
+ NSWorkspace.shared.open(downloadsURL)
+ }
+ }
+ else {
+ let fileURLs = self.selectedTransfers.compactMap(\.fileURL)
+ if !fileURLs.isEmpty {
+ NSWorkspace.shared.activateFileViewerSelecting(fileURLs)
+ }
+ }
+ } label: {
+ Label("Show Downloads", systemImage: "folder")
+ }
+ .help("Show Downloads")
+ }
ToolbarItem(placement: .primaryAction) {
Button {
@@ -33,15 +42,16 @@ struct TransfersView: View {
}
self.selectedTransfers = []
} label: {
- Label("Cancel Transfer", systemImage: "xmark")
+ Label(self.selectedTransfers.count == 1 ? "Remove Transfer" : "Remove Transfers", systemImage: "xmark")
}
.disabled(self.selectedTransfers.isEmpty)
+ .help(self.selectedTransfers.count == 1 ? "Remove Transfer" : "Remove Transfers")
}
}
}
-
+
// MARK: - Empty State
-
+
private var emptyState: some View {
ContentUnavailableView {
Label("No Transfers", systemImage: "arrow.up.arrow.down")
@@ -49,9 +59,9 @@ struct TransfersView: View {
Text("Your Hotline file transfers will appear here")
}
}
-
+
// MARK: - Transfers List
-
+
private var transfersList: some View {
List(selection: self.$selectedTransfers) {
ForEach(self.appState.transfers) { transfer in
@@ -62,40 +72,184 @@ struct TransfersView: View {
.listStyle(.inset)
.environment(\.defaultMinListRowHeight, 56)
.contextMenu(forSelectionType: TransferInfo.self) { items in
- if let item = items.first {
- if item.completed,
- let fileURL = item.fileURL {
- Button("Remove Transfer") {
- self.appState.cancelTransfer(id: item.id)
+ 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("Show in Finder") {
- NSWorkspace.shared.activateFileViewerSelecting([fileURL])
+ let fileURLs: [URL] = items.compactMap(\.fileURL)
+ if !fileURLs.isEmpty {
+ NSWorkspace.shared.recycle(fileURLs)
}
-
- Button("Open") {
- NSWorkspace.shared.open(fileURL)
+
+ self.selectedTransfers = []
+ }
+ }
+ } primaryAction: { items in
+ if let fileURL = items.first?.fileURL {
+ NSWorkspace.shared.open(fileURL)
+ }
+ }
+ }
+
+ private func getOpenWithApps(for fileURLs: [URL], defaultAppURL: URL? = nil) -> [(name: String, url: URL)] {
+ // If no files provided, there is no common app to open them
+ guard !fileURLs.isEmpty else { return [] }
+
+ // Build a list of app URL sets for each file URL
+ let appSets: [Set<URL>] = fileURLs.map { url in
+ let apps = NSWorkspace.shared.urlsForApplications(toOpen: url)
+ return Set(apps)
+ }
+
+ // Compute the intersection across all file URL app sets
+ guard var intersection = appSets.first else { return [] }
+ for set in appSets.dropFirst() {
+ intersection.formIntersection(set)
+ }
+
+ // Optionally remove the default app from the list
+ if let defaultAppURL {
+ intersection.remove(defaultAppURL)
+ }
+
+ // Map to display names and sort by name
+ let result: [(name: String, url: URL)] = intersection.compactMap { url in
+ let appName = FileManager.default
+ .displayName(atPath: url.path)
+ .replacingOccurrences(of: ".app", with: "")
+ return (name: appName, url: url)
+ }.sorted { $0.name < $1.name }
+
+ return result
+ }
+
+ private func getDefaultApp(for fileURLs: [URL]) -> (name: String, url: URL)? {
+ // No files -> no default app
+ guard !fileURLs.isEmpty else { return nil }
+
+ // Single file: use the system default directly
+ if fileURLs.count == 1, let url = NSWorkspace.shared.urlForApplication(toOpen: fileURLs[0]) {
+ let name = FileManager.default
+ .displayName(atPath: url.path)
+ .replacingOccurrences(of: ".app", with: "")
+ return (name, url)
+ }
+
+ // Build the intersection of apps that can open ALL files
+ let appSets: [Set<URL>] = fileURLs.map { url in
+ Set(NSWorkspace.shared.urlsForApplications(toOpen: url))
+ }
+ guard var intersection = appSets.first else { return nil }
+ for set in appSets.dropFirst() {
+ intersection.formIntersection(set)
+ if intersection.isEmpty { return nil }
+ }
+
+ // Tally the system default app for each file
+ var defaultCounts: [URL: Int] = [:]
+ for fileURL in fileURLs {
+ if let def = NSWorkspace.shared.urlForApplication(toOpen: fileURL) {
+ defaultCounts[def, default: 0] += 1
+ }
+ }
+
+ // Prefer the app that's the default for the majority of files, provided it can open all
+ if let bestByMajority = intersection.max(by: { (a, b) -> Bool in
+ let ca = defaultCounts[a, default: 0]
+ let cb = defaultCounts[b, default: 0]
+ if ca == cb {
+ // Tie-breaker deferred to later
+ return false
+ }
+ return ca < cb
+ }),
+ defaultCounts[bestByMajority, default: 0] > 0 {
+ let name = FileManager.default
+ .displayName(atPath: bestByMajority.path)
+ .replacingOccurrences(of: ".app", with: "")
+ return (name, bestByMajority)
+ }
+
+ return nil
+ }
+
+ private func openWithMenu(for fileURLs: [URL]) -> some View {
+ Menu("Open With") {
+ let defaultApp: (name: String, url: URL)? = self.getDefaultApp(for: fileURLs)
+ let apps: [(name: String, url: URL)] = self.getOpenWithApps(for: fileURLs, defaultAppURL: defaultApp?.url)
+
+ if let defaultApp {
+ Button {
+ NSWorkspace.shared.open(fileURLs, withApplicationAt: defaultApp.url, configuration: NSWorkspace.OpenConfiguration())
+ } label: {
+ Label {
+ Text(defaultApp.name)
+ } icon: {
+ Image(nsImage: NSWorkspace.shared.icon(forFile: defaultApp.url.path))
+ .resizable()
+ .scaledToFit()
+ .frame(width: 16, height: 16)
}
-
+ }
+
+ if !apps.isEmpty {
Divider()
-
- Button("Move to Trash") {
- NSWorkspace.shared.recycle([fileURL])
- }
}
- else if !item.done {
- Button("Cancel Transfer") {
- self.appState.cancelTransfer(id: item.id)
+ }
+
+ if !apps.isEmpty {
+ ForEach(apps, id: \.url) { app in
+ Button {
+ NSWorkspace.shared.open(fileURLs, withApplicationAt: app.url, configuration: NSWorkspace.OpenConfiguration())
+ } label: {
+ Label {
+ Text(app.name)
+ } icon: {
+ Image(nsImage: NSWorkspace.shared.icon(forFile: app.url.path))
+ .resizable()
+ .scaledToFit()
+ .frame(width: 16, height: 16)
+ }
}
}
}
- } primaryAction: { items in
- let fileURLs: [URL] = items.compactMap { $0.fileURL }
- if !fileURLs.isEmpty {
- NSWorkspace.shared.activateFileViewerSelecting(fileURLs)
- }
}
}
}
@@ -110,20 +264,24 @@ struct TransferRow: View {
private var statsView: some View {
HStack(spacing: 8) {
// Progress percentage
-// Text("\(Int(self.transfer.progress * 100))%")
+ // Text("\(Int(self.transfer.progress * 100))%")
// Speed
if let speed = self.transfer.speed {
- Text(self.formatSpeed(speed))
+ // TODO: Use arrow.up for uploads.
+ Label(self.formatSpeed(speed), systemImage: "arrow.down")
+// Text(self.formatSpeed(speed))
}
-
+
// Time remaining
if let timeRemaining = self.transfer.timeRemaining {
- Text(self.formatTimeRemaining(timeRemaining))
+ Label(self.formatTimeRemaining(timeRemaining), systemImage: "clock")
+// Text(self.formatTimeRemaining(timeRemaining))
}
// File size
- Text(self.formatSize(self.transfer.size))
+ Label(self.formatSize(self.transfer.size), systemImage: "document")
+// Text(self.formatSize(self.transfer.size))
}
.font(.subheadline)
.foregroundStyle(.secondary)
@@ -151,13 +309,13 @@ struct TransferRow: View {
}
}
}
-
+
var body: some View {
HStack(alignment: .center, spacing: 8) {
self.fileIconView
- VStack(alignment: .leading, spacing: 4) {
- HStack(alignment: .firstTextBaseline, spacing: 4) {
+ VStack(alignment: .leading, spacing: 2) {
+ HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(self.transfer.title)
.font(.headline)
.lineLimit(1)
@@ -182,7 +340,7 @@ struct TransferRow: View {
.foregroundStyle(.secondary)
}
else if self.transfer.completed {
- Text("Complete")
+ Text("Downloaded")
.font(.subheadline)
.foregroundStyle(.fileComplete)
}
@@ -192,65 +350,25 @@ struct TransferRow: View {
.controlSize(.large)
}
}
-
- if self.transfer.completed {
- Button {
- guard let fileURL = self.transfer.fileURL else {
- return
- }
-
- NSWorkspace.shared.activateFileViewerSelecting([fileURL])
- } label: {
- Image(systemName: "eye.circle.fill")
- .resizable()
- .scaledToFit()
- .frame(width: 24, height: 24)
- .foregroundStyle(.secondary)
- }
- .buttonBorderShape(.circle)
- .buttonStyle(.plain)
- }
}
-
-// VStack(alignment: .leading, spacing: 2) {
-
-
-// if let serverName = self.transfer.serverName {
-// Text(serverName)
-// .font(.caption)
-// .foregroundStyle(.secondary)
-// }
-// }
-
-// // Cancel button
-// Button {
-// self.appState.cancelTransfer(id: transfer.id)
-// } label: {
-// Image(systemName: "xmark.circle.fill")
-// .foregroundStyle(.secondary)
-// }
-// .buttonStyle(.plain)
-// .help("Cancel download")
-// }
-// }
}
-
+
// MARK: - Formatting
-
+
private func formatSize(_ bytes: UInt) -> String {
let formatter = ByteCountFormatter()
formatter.countStyle = .file
formatter.allowedUnits = [.useKB, .useMB, .useGB]
return formatter.string(fromByteCount: Int64(bytes))
}
-
+
private func formatSpeed(_ bytesPerSecond: Double) -> String {
let formatter = ByteCountFormatter()
formatter.countStyle = .file
formatter.allowedUnits = [.useKB, .useMB, .useGB]
return "\(formatter.string(fromByteCount: Int64(bytesPerSecond)))/s"
}
-
+
private func formatTimeRemaining(_ seconds: TimeInterval) -> String {
if seconds < 60 {
return "\(Int(seconds))s"
@@ -272,3 +390,4 @@ struct TransferRow: View {
TransfersView()
.environment(AppState.shared)
}
+