From ac039a50c8136f43e30b6577ba90d62b506bdf5f Mon Sep 17 00:00:00 2001 From: nimbold <11913706+nimbold@users.noreply.github.com> Date: Tue, 2 Jun 2026 04:48:10 +0330 Subject: [PATCH] feat: add queue management --- Sources/Firelink/AddDownloadsView.swift | 6 +- Sources/Firelink/ContentView.swift | 39 ++++-- Sources/Firelink/DownloadController.swift | 122 +++++++++++++++-- Sources/Firelink/DownloadTable.swift | 152 +++++++++++++++++----- Sources/Firelink/Models.swift | 16 +++ Sources/Firelink/SidebarView.swift | 49 ++++++- 6 files changed, 326 insertions(+), 58 deletions(-) diff --git a/Sources/Firelink/AddDownloadsView.swift b/Sources/Firelink/AddDownloadsView.swift index c0becbc..ae74e9c 100644 --- a/Sources/Firelink/AddDownloadsView.swift +++ b/Sources/Firelink/AddDownloadsView.swift @@ -11,6 +11,7 @@ struct AddDownloadsView: View { @State private var overrideDestination = false @State private var destinationPath = "" @State private var metadataTask: Task? + @State private var targetQueueID = DownloadQueue.mainQueueID var body: some View { VStack(spacing: 0) { @@ -33,6 +34,8 @@ struct AddDownloadsView: View { } .onAppear { connectionsPerServer = Double(settings.perServerConnections) + targetQueueID = controller.pendingAddQueueID ?? DownloadQueue.mainQueueID + controller.pendingAddQueueID = nil if let text = controller.pendingPasteboardText { linkText = text controller.pendingPasteboardText = nil @@ -290,7 +293,8 @@ struct AddDownloadsView: View { pendingDownloads, connectionsPerServer: Int(connectionsPerServer), overrideDirectory: overrideDirectory, - startImmediately: start + startImmediately: start, + queueID: targetQueueID ) dismiss() } diff --git a/Sources/Firelink/ContentView.swift b/Sources/Firelink/ContentView.swift index b859b23..f2fb433 100644 --- a/Sources/Firelink/ContentView.swift +++ b/Sources/Firelink/ContentView.swift @@ -22,17 +22,38 @@ struct ContentView: View { switch sidebarSelection { case .downloads(let filter): downloadsView(filter: filter) + case .queue(let queueID): + queueView(queueID: queueID) case .settings: SettingsView() } } + private func queueView(queueID: UUID) -> some View { + downloadsView( + filter: .all, + title: controller.queueName(for: queueID), + items: controller.queueItems(for: queueID), + queueID: queueID + ) + } + private func downloadsView(filter: DownloadSidebarFilter) -> some View { + downloadsView( + filter: filter, + title: filter.title, + items: filteredDownloads(for: filter), + queueID: nil + ) + } + + private func downloadsView(filter: DownloadSidebarFilter, title: String, items: [DownloadItem], queueID: UUID?) -> some View { VStack(spacing: 0) { DownloadTable( - items: filteredDownloads(for: filter), + items: items, selection: $selection, - title: filter.title + title: title, + queueID: queueID ) Divider() StatusBar() @@ -40,6 +61,7 @@ struct ContentView: View { .toolbar { ToolbarItem(placement: .primaryAction) { Button { + controller.pendingAddQueueID = queueID openWindow(id: "add-downloads") } label: { Label("Add", systemImage: "plus") @@ -48,7 +70,7 @@ struct ContentView: View { ToolbarItem { Button { - controller.startQueue() + controller.startQueue(queueID: queueID) } label: { Label("Start Queue", systemImage: "play.fill") } @@ -94,13 +116,13 @@ struct ContentView: View { .opacity(0) Button("") { - handlePaste() + handlePaste(queueID: queueID) } .keyboardShortcut("v", modifiers: .command) .opacity(0) Button("") { - selectAll(filter: filter) + selectAll(items: items) } .keyboardShortcut("a", modifiers: .command) .opacity(0) @@ -132,14 +154,15 @@ struct ContentView: View { selection.removeAll() } - private func handlePaste() { + private func handlePaste(queueID: UUID?) { guard let text = NSPasteboard.general.string(forType: .string), !text.isEmpty else { return } controller.pendingPasteboardText = text + controller.pendingAddQueueID = queueID openWindow(id: "add-downloads") } - private func selectAll(filter: DownloadSidebarFilter) { - selection = Set(filteredDownloads(for: filter).map { $0.id }) + private func selectAll(items: [DownloadItem]) { + selection = Set(items.map { $0.id }) } private func filteredDownloads(for filter: DownloadSidebarFilter) -> [DownloadItem] { diff --git a/Sources/Firelink/DownloadController.swift b/Sources/Firelink/DownloadController.swift index ffda9e9..d62537a 100644 --- a/Sources/Firelink/DownloadController.swift +++ b/Sources/Firelink/DownloadController.swift @@ -5,8 +5,10 @@ import Foundation @MainActor final class DownloadController: ObservableObject { @Published var downloads: [DownloadItem] = [] + @Published var queues: [DownloadQueue] = [.main] @Published var engineMessage = "" @Published var pendingPasteboardText: String? + var pendingAddQueueID: UUID? private let settings: AppSettings private let engine = Aria2DownloadEngine() @@ -81,7 +83,7 @@ final class DownloadController: ObservableObject { Aria2DownloadEngine.findExecutable() != nil } - func add(urlText: String, connectionsPerServer: Int? = nil) { + func add(urlText: String, connectionsPerServer: Int? = nil, queueID: UUID = DownloadQueue.mainQueueID) { guard let url = URL(string: urlText.trimmingCharacters(in: .whitespacesAndNewlines)), let scheme = url.scheme?.lowercased(), ["http", "https", "ftp", "sftp"].contains(scheme) else { @@ -97,7 +99,8 @@ final class DownloadController: ObservableObject { category: category, destinationDirectory: settings.destinationDirectory(for: category), connectionsPerServer: min(max(connectionsPerServer ?? settings.perServerConnections, 1), 16), - credentials: settings.credentials(for: url) + credentials: settings.credentials(for: url), + queueID: normalizedQueueID(queueID) ) downloads.append(item) @@ -109,9 +112,11 @@ final class DownloadController: ObservableObject { _ pendingDownloads: [PendingDownload], connectionsPerServer: Int, overrideDirectory: URL?, - startImmediately: Bool + startImmediately: Bool, + queueID: UUID = DownloadQueue.mainQueueID ) { let clampedConnections = min(max(connectionsPerServer, 1), 16) + let targetQueueID = normalizedQueueID(queueID) let items = pendingDownloads.map { pending in DownloadItem( @@ -123,7 +128,8 @@ final class DownloadController: ObservableObject { credentials: settings.credentials(for: pending.url), sizeBytes: pending.sizeBytes, bytesText: ByteFormatter.string(pending.sizeBytes), - message: startImmediately ? "Queued to start" : "Added to queue" + message: startImmediately ? "Queued to start" : "Added to queue", + queueID: targetQueueID ) } @@ -132,14 +138,14 @@ final class DownloadController: ObservableObject { saveDownloads() if startImmediately { - startQueue() + startQueue(queueID: targetQueueID) } } - func startQueue() { + func startQueue(queueID: UUID? = nil) { engineMessage = "" restrictQueueToAutoResume = false - markQueuedDownloadsForAutoResume() + markQueuedDownloadsForAutoResume(queueID: queueID) pumpQueue() } @@ -228,6 +234,60 @@ final class DownloadController: ObservableObject { saveDownloads() } + func queueName(for id: UUID) -> String { + queues.first(where: { $0.id == normalizedQueueID(id) })?.name ?? DownloadQueue.main.name + } + + func queueItems(for id: UUID) -> [DownloadItem] { + let id = normalizedQueueID(id) + return downloads.filter { normalizedQueueID($0.queueID) == id } + } + + func queueCount(for id: UUID) -> Int { + queueItems(for: id).count + } + + @discardableResult + func addQueue() -> DownloadQueue { + var index = 2 + var name = "Queue \(index)" + let existingNames = Set(queues.map(\.name)) + while existingNames.contains(name) { + index += 1 + name = "Queue \(index)" + } + + let queue = DownloadQueue(name: name) + queues.append(queue) + saveDownloads() + return queue + } + + func renameQueue(id: UUID, name: String) { + let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleanName.isEmpty, + id != DownloadQueue.mainQueueID, + let index = queues.firstIndex(where: { $0.id == id }) else { + return + } + + queues[index].name = cleanName + saveDownloads() + } + + func moveDownload(_ itemID: UUID, before targetID: UUID, in queueID: UUID) { + let queueID = normalizedQueueID(queueID) + guard itemID != targetID, + let source = downloads.firstIndex(where: { $0.id == itemID && normalizedQueueID($0.queueID) == queueID }), + let target = downloads.firstIndex(where: { $0.id == targetID && normalizedQueueID($0.queueID) == queueID }) else { + return + } + + let item = downloads.remove(at: source) + downloads.insert(item, at: target) + saveDownloads() + } + private func pumpQueue() { guard hasAria2 else { engineMessage = "aria2c is not installed. Run `brew install aria2` to enable downloads." @@ -343,8 +403,10 @@ final class DownloadController: ObservableObject { saveDownloads() } - private func markQueuedDownloadsForAutoResume() { - for index in downloads.indices where downloads[index].status == .queued { + private func markQueuedDownloadsForAutoResume(queueID: UUID?) { + let normalizedID = queueID.map(normalizedQueueID) + for index in downloads.indices where downloads[index].status == .queued && + (normalizedID == nil || normalizedQueueID(downloads[index].queueID) == normalizedID) { downloads[index].autoResumeOnLaunch = true } saveDownloads() @@ -431,7 +493,8 @@ final class DownloadController: ObservableObject { do { let directory = storageURL.deletingLastPathComponent() try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) - let data = try JSONEncoder().encode(downloads) + let state = StoredDownloadState(queues: queues, downloads: downloads) + let data = try JSONEncoder().encode(state) try data.write(to: storageURL, options: .atomic) } catch { print("Failed to save downloads: \(error)") @@ -442,11 +505,21 @@ final class DownloadController: ObservableObject { do { guard FileManager.default.fileExists(atPath: storageURL.path) else { return false } let data = try Data(contentsOf: storageURL) - let loaded = try JSONDecoder().decode([DownloadItem].self, from: data) + let state: StoredDownloadState + if let storedState = try? JSONDecoder().decode(StoredDownloadState.self, from: data) { + state = storedState + } else { + state = StoredDownloadState( + queues: [.main], + downloads: try JSONDecoder().decode([DownloadItem].self, from: data) + ) + } var shouldResumeRecoveredDownloads = false - self.downloads = loaded.map { item in + self.queues = normalizedQueues(state.queues) + self.downloads = state.downloads.map { item in var adjusted = item + adjusted.queueID = normalizedQueueID(adjusted.queueID) if adjusted.status == .downloading { adjusted.status = .queued adjusted.message = "Recovered after restart. Resuming from partial file." @@ -471,6 +544,31 @@ final class DownloadController: ObservableObject { return false } } + + private func normalizedQueueID(_ id: UUID?) -> UUID { + guard let id, queues.contains(where: { $0.id == id }) else { + return DownloadQueue.mainQueueID + } + return id + } + + private func normalizedQueues(_ queues: [DownloadQueue]) -> [DownloadQueue] { + var normalized = queues + if !normalized.contains(where: { $0.id == DownloadQueue.mainQueueID }) { + normalized.insert(.main, at: 0) + } + + if let mainIndex = normalized.firstIndex(where: { $0.id == DownloadQueue.mainQueueID }), mainIndex != 0 { + let main = normalized.remove(at: mainIndex) + normalized.insert(main, at: 0) + } + return normalized + } +} + +private struct StoredDownloadState: Codable { + var queues: [DownloadQueue] + var downloads: [DownloadItem] } private final class SleepActivityHandle: @unchecked Sendable { diff --git a/Sources/Firelink/DownloadTable.swift b/Sources/Firelink/DownloadTable.swift index 3d7a247..10eab05 100644 --- a/Sources/Firelink/DownloadTable.swift +++ b/Sources/Firelink/DownloadTable.swift @@ -1,7 +1,9 @@ import AppKit import SwiftUI +import UniformTypeIdentifiers enum DownloadColumn: String, CaseIterable, Identifiable, Codable { + case priority = "#" case fileName = "File name" case size = "Size" case progress = "Progress" @@ -21,6 +23,7 @@ enum DownloadColumn: String, CaseIterable, Identifiable, Codable { var width: CGFloat { switch self { + case .priority: return 58 case .fileName: return 340 case .size: return 100 case .status: return 105 @@ -104,11 +107,13 @@ struct DownloadTable: View { let items: [DownloadItem] @Binding var selection: Set let title: String + var queueID: UUID? @StateObject private var tableSettings = TableSettings() @State private var pendingDeleteItems: Set? @State private var resizeBaseWidths: [DownloadColumn: CGFloat] = [:] @State private var lastSelectedIndex: Int? + @State private var draggedItemID: DownloadItem.ID? var body: some View { VStack(spacing: 0) { @@ -201,6 +206,7 @@ struct DownloadTable: View { private func tableRow(for item: DownloadItem, tableWidth: CGFloat, trailingWidth: CGFloat) -> some View { DownloadRow( item: item, + priorityNumber: priorityNumber(for: item), visibleColumns: orderedVisibleColumns, columnWidth: { width(for: $0) }, trailingWidth: trailingWidth @@ -231,39 +237,26 @@ struct DownloadTable: View { .contextMenu { rowContextMenu(for: item) } + .onDrag { + draggedItemID = item.id + return NSItemProvider(object: item.id.uuidString as NSString) + } + .onDrop( + of: [.text], + delegate: QueueDropDelegate( + item: item, + queueID: queueID, + draggedItemID: $draggedItemID, + controller: controller + ) + ) } private func tableHeader(trailingWidth: CGFloat) -> some View { HStack(spacing: 0) { ForEach(orderedVisibleColumns) { column in ZStack(alignment: .trailing) { - Button { - if tableSettings.sortColumn == column { - tableSettings.sortDirection.toggle() - } else { - tableSettings.sortColumn = column - tableSettings.sortDirection = .ascending - } - } label: { - HStack(spacing: 4) { - Spacer(minLength: 0) - Text(column.rawValue) - .font(.caption.weight(.semibold)) - .lineLimit(1) - .truncationMode(.tail) - .multilineTextAlignment(.center) - .layoutPriority(1) - if tableSettings.sortColumn == column { - Image(systemName: tableSettings.sortDirection == .ascending ? "chevron.up" : "chevron.down") - .font(.caption2) - } - Spacer(minLength: 0) - } - .padding(.horizontal, 8) - .frame(width: width(for: column), height: 34, alignment: .center) - .clipped() - } - .buttonStyle(.plain) + headerContent(for: column) Rectangle() .fill(.secondary.opacity(0.18)) @@ -301,7 +294,7 @@ struct DownloadTable: View { .background(.bar) .contextMenu { Section("Columns") { - ForEach(DownloadColumn.allCases) { column in + ForEach(availableColumns) { column in Toggle(column.rawValue, isOn: Binding( get: { tableSettings.visibleColumns.contains(column) }, set: { isVisible in @@ -314,10 +307,12 @@ struct DownloadTable: View { )) } } - Section("Sort By") { - ForEach(DownloadColumn.allCases) { column in - Button(column.rawValue) { - tableSettings.sortColumn = column + if queueID == nil { + Section("Sort By") { + ForEach(availableColumns) { column in + Button(column.rawValue) { + tableSettings.sortColumn = column + } } } } @@ -386,7 +381,17 @@ struct DownloadTable: View { } private var orderedVisibleColumns: [DownloadColumn] { - DownloadColumn.allCases.filter { tableSettings.visibleColumns.contains($0) } + var columns = DownloadColumn.allCases.filter { tableSettings.visibleColumns.contains($0) } + if queueID != nil, !columns.contains(.priority) { + columns.insert(.priority, at: 0) + } else if queueID == nil { + columns.removeAll { $0 == .priority } + } + return columns + } + + private var availableColumns: [DownloadColumn] { + DownloadColumn.allCases.filter { $0 != .priority } } private var totalWidth: CGFloat { @@ -398,7 +403,11 @@ struct DownloadTable: View { } private var sortedItems: [DownloadItem] { - items.sorted { lhs, rhs in + if queueID != nil { + return items + } + + return items.sorted { lhs, rhs in let result = compare(lhs, rhs, by: tableSettings.sortColumn) if result == .orderedSame { return lhs.id.uuidString < rhs.id.uuidString @@ -409,6 +418,8 @@ struct DownloadTable: View { private func compare(_ lhs: DownloadItem, _ rhs: DownloadItem, by column: DownloadColumn) -> ComparisonResult { switch column { + case .priority: + return compare(priorityNumber(for: lhs) ?? 0, priorityNumber(for: rhs) ?? 0) case .fileName: return lhs.fileName.localizedCaseInsensitiveCompare(rhs.fileName) case .size: return compare(lhs.sizeBytes ?? -1, rhs.sizeBytes ?? -1) case .status: return compare(lhs.status.rawValue, rhs.status.rawValue) @@ -426,6 +437,51 @@ struct DownloadTable: View { } } + @ViewBuilder + private func headerContent(for column: DownloadColumn) -> some View { + let content = HStack(spacing: 4) { + Spacer(minLength: 0) + Text(column.rawValue) + .font(.caption.weight(.semibold)) + .lineLimit(1) + .truncationMode(.tail) + .multilineTextAlignment(.center) + .layoutPriority(1) + if queueID == nil && tableSettings.sortColumn == column { + Image(systemName: tableSettings.sortDirection == .ascending ? "chevron.up" : "chevron.down") + .font(.caption2) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 8) + .frame(width: width(for: column), height: 34, alignment: .center) + .clipped() + + if queueID == nil { + Button { + if tableSettings.sortColumn == column { + tableSettings.sortDirection.toggle() + } else { + tableSettings.sortColumn = column + tableSettings.sortDirection = .ascending + } + } label: { + content + } + .buttonStyle(.plain) + } else { + content + } + } + + private func priorityNumber(for item: DownloadItem) -> Int? { + guard queueID != nil, + let index = items.firstIndex(where: { $0.id == item.id }) else { + return nil + } + return index + 1 + } + private func compare(_ lhs: T, _ rhs: T) -> ComparisonResult { if lhs < rhs { return .orderedAscending } if lhs > rhs { return .orderedDescending } @@ -456,6 +512,7 @@ struct DownloadTable: View { private struct DownloadRow: View { let item: DownloadItem + let priorityNumber: Int? let visibleColumns: [DownloadColumn] let columnWidth: (DownloadColumn) -> CGFloat let trailingWidth: CGFloat @@ -480,6 +537,11 @@ private struct DownloadRow: View { @ViewBuilder private func cell(for column: DownloadColumn) -> some View { switch column { + case .priority: + Text(priorityNumber.map(String.init) ?? "-") + .monospacedDigit() + .foregroundStyle(.secondary) + .lineLimit(1) case .fileName: HStack(alignment: .top, spacing: 8) { Image(systemName: item.category.symbolName) @@ -610,3 +672,25 @@ private struct DownloadRow: View { return date.formatted(date: .abbreviated, time: .shortened) } } + +private struct QueueDropDelegate: DropDelegate { + let item: DownloadItem + let queueID: UUID? + @Binding var draggedItemID: DownloadItem.ID? + let controller: DownloadController + + func dropEntered(info: DropInfo) { + guard let queueID, + let draggedItemID, + draggedItemID != item.id else { + return + } + + controller.moveDownload(draggedItemID, before: item.id, in: queueID) + } + + func performDrop(info: DropInfo) -> Bool { + draggedItemID = nil + return queueID != nil + } +} diff --git a/Sources/Firelink/Models.swift b/Sources/Firelink/Models.swift index ff384de..e15623f 100644 --- a/Sources/Firelink/Models.swift +++ b/Sources/Firelink/Models.swift @@ -29,6 +29,21 @@ enum DownloadCategory: String, Codable, CaseIterable, Sendable { } } +struct DownloadQueue: Identifiable, Codable, Equatable, Sendable { + static let mainQueueID = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! + + var id = UUID() + var name: String + + var isMain: Bool { + id == Self.mainQueueID + } + + static var main: DownloadQueue { + DownloadQueue(id: mainQueueID, name: "Main queue") + } +} + struct DownloadCredentials: Codable, Equatable, Sendable { var username: String var password: String @@ -58,6 +73,7 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable { var createdAt = Date() var lastTryAt: Date? var autoResumeOnLaunch: Bool? + var queueID: UUID? var destinationPath: String { destinationDirectory.appendingPathComponent(fileName).path diff --git a/Sources/Firelink/SidebarView.swift b/Sources/Firelink/SidebarView.swift index 264c781..331c6a1 100644 --- a/Sources/Firelink/SidebarView.swift +++ b/Sources/Firelink/SidebarView.swift @@ -22,12 +22,15 @@ enum DownloadSidebarFilter: Hashable { enum SidebarSelection: Hashable { case downloads(DownloadSidebarFilter) + case queue(UUID) case settings } struct SidebarView: View { @EnvironmentObject private var controller: DownloadController @Binding var selection: SidebarSelection + @State private var queueBeingRenamed: DownloadQueue? + @State private var queueName = "" var body: some View { List(selection: $selection) { @@ -35,9 +38,6 @@ struct SidebarView: View { Label("All", systemImage: "tray.full") .badge(controller.downloads.count) .tag(SidebarSelection.downloads(.all)) - Label("Queue", systemImage: "list.bullet") - .badge(controller.queuedCount) - .tag(SidebarSelection.downloads(.queued)) Label("Active", systemImage: "bolt.fill") .badge(controller.activeCount) .tag(SidebarSelection.downloads(.active)) @@ -56,8 +56,51 @@ struct SidebarView: View { .tag(SidebarSelection.downloads(.category(category))) } } + + Section("Queues") { + ForEach(controller.queues) { queue in + Label(queue.name, systemImage: queue.isMain ? "list.bullet.rectangle" : "list.bullet") + .badge(controller.queueCount(for: queue.id)) + .tag(SidebarSelection.queue(queue.id)) + .contextMenu { + if !queue.isMain { + Button("Rename") { + queueBeingRenamed = queue + queueName = queue.name + } + } + } + } + + Button { + let queue = controller.addQueue() + selection = .queue(queue.id) + } label: { + Label("Add new queue", systemImage: "plus") + } + .buttonStyle(.plain) + } } .listStyle(.sidebar) + .alert("Rename Queue", isPresented: Binding( + get: { queueBeingRenamed != nil }, + set: { isPresented in + if !isPresented { + queueBeingRenamed = nil + } + } + )) { + TextField("Queue name", text: $queueName) + Button("Rename") { + if let queue = queueBeingRenamed { + controller.renameQueue(id: queue.id, name: queueName) + } + queueBeingRenamed = nil + } + Button("Cancel", role: .cancel) { + queueBeingRenamed = nil + } + } .safeAreaInset(edge: .bottom) { VStack(spacing: 8) { Divider()