diff --git a/Sources/Firelink/ContentView.swift b/Sources/Firelink/ContentView.swift index f2fb433..25007e2 100644 --- a/Sources/Firelink/ContentView.swift +++ b/Sources/Firelink/ContentView.swift @@ -175,8 +175,8 @@ struct ContentView: View { controller.downloads.filter { $0.status == .downloading } case .completed: controller.downloads.filter { $0.status == .completed } - case .failed: - controller.downloads.filter { $0.status == .failed } + case .unfinished: + controller.downloads.filter { $0.status != .completed } case .category(let category): controller.downloads.filter { $0.category == category } } diff --git a/Sources/Firelink/DownloadController.swift b/Sources/Firelink/DownloadController.swift index d62537a..550fc19 100644 --- a/Sources/Firelink/DownloadController.swift +++ b/Sources/Firelink/DownloadController.swift @@ -75,8 +75,8 @@ final class DownloadController: ObservableObject { downloads.filter { $0.status == .completed }.count } - var failedCount: Int { - downloads.filter { $0.status == .failed }.count + var unfinishedCount: Int { + downloads.filter { $0.status != .completed }.count } var hasAria2: Bool { @@ -182,6 +182,30 @@ final class DownloadController: ObservableObject { updateSleepActivity() } + func assignToQueue(itemIDs: Set, queueID: UUID) { + let queueID = normalizedQueueID(queueID) + var changed = false + + for index in downloads.indices where itemIDs.contains(downloads[index].id) { + guard downloads[index].status != .completed, + downloads[index].status != .downloading else { + continue + } + + downloads[index].status = .queued + downloads[index].queueID = queueID + downloads[index].message = "Added to \(queueName(for: queueID))" + downloads[index].autoResumeOnLaunch = false + automaticRetryCounts[downloads[index].id] = nil + changed = true + } + + if changed { + saveDownloads() + updateSleepActivity() + } + } + func resume(_ item: DownloadItem) { restrictQueueToAutoResume = false update(item.id) { @@ -240,7 +264,7 @@ final class DownloadController: ObservableObject { func queueItems(for id: UUID) -> [DownloadItem] { let id = normalizedQueueID(id) - return downloads.filter { normalizedQueueID($0.queueID) == id } + return downloads.filter { validQueueID($0.queueID) == id } } func queueCount(for id: UUID) -> Int { @@ -275,11 +299,25 @@ final class DownloadController: ObservableObject { saveDownloads() } + func removeQueue(id: UUID) { + guard id != DownloadQueue.mainQueueID, + queues.contains(where: { $0.id == id }) else { + return + } + + for index in downloads.indices where validQueueID(downloads[index].queueID) == id { + downloads[index].queueID = nil + } + queues.removeAll { $0.id == id } + engineMessage = "Removed queue. Downloads remain in Unfinished." + 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 { + let source = downloads.firstIndex(where: { $0.id == itemID && validQueueID($0.queueID) == queueID }), + let target = downloads.firstIndex(where: { $0.id == targetID && validQueueID($0.queueID) == queueID }) else { return } @@ -506,20 +544,26 @@ final class DownloadController: ObservableObject { guard FileManager.default.fileExists(atPath: storageURL.path) else { return false } let data = try Data(contentsOf: storageURL) let state: StoredDownloadState + let isLegacyDownloadList: Bool if let storedState = try? JSONDecoder().decode(StoredDownloadState.self, from: data) { state = storedState + isLegacyDownloadList = false } else { state = StoredDownloadState( queues: [.main], downloads: try JSONDecoder().decode([DownloadItem].self, from: data) ) + isLegacyDownloadList = true } var shouldResumeRecoveredDownloads = false self.queues = normalizedQueues(state.queues) self.downloads = state.downloads.map { item in var adjusted = item - adjusted.queueID = normalizedQueueID(adjusted.queueID) + adjusted.queueID = validQueueID(adjusted.queueID) + if isLegacyDownloadList, item.queueID == nil { + adjusted.queueID = DownloadQueue.mainQueueID + } if adjusted.status == .downloading { adjusted.status = .queued adjusted.message = "Recovered after restart. Resuming from partial file." @@ -546,8 +590,12 @@ final class DownloadController: ObservableObject { } private func normalizedQueueID(_ id: UUID?) -> UUID { + validQueueID(id) ?? DownloadQueue.mainQueueID + } + + private func validQueueID(_ id: UUID?) -> UUID? { guard let id, queues.contains(where: { $0.id == id }) else { - return DownloadQueue.mainQueueID + return nil } return id } diff --git a/Sources/Firelink/DownloadTable.swift b/Sources/Firelink/DownloadTable.swift index 10eab05..8a5370b 100644 --- a/Sources/Firelink/DownloadTable.swift +++ b/Sources/Firelink/DownloadTable.swift @@ -239,7 +239,7 @@ struct DownloadTable: View { } .onDrag { draggedItemID = item.id - return NSItemProvider(object: item.id.uuidString as NSString) + return NSItemProvider(object: dragPayload(for: item) as NSString) } .onDrop( of: [.text], @@ -361,10 +361,15 @@ struct DownloadTable: View { } } - if targetItems.contains(where: { $0.status != .downloading && $0.status != .queued }) { - Button { - for target in targetItems where target.status != .downloading && target.status != .queued { - controller.queue(target) + if targetItems.contains(where: { $0.status != .completed && $0.status != .downloading }) { + Menu { + ForEach(controller.queues) { queue in + Button(queue.name) { + controller.assignToQueue( + itemIDs: Set(targetItems.map(\.id)), + queueID: queue.id + ) + } } } label: { Label("Add to Queue", systemImage: "list.bullet") @@ -482,6 +487,13 @@ struct DownloadTable: View { return index + 1 } + private func dragPayload(for item: DownloadItem) -> String { + let draggedIDs = selection.contains(item.id) ? selection : [item.id] + return draggedIDs + .map(\.uuidString) + .joined(separator: "\n") + } + private func compare(_ lhs: T, _ rhs: T) -> ComparisonResult { if lhs < rhs { return .orderedAscending } if lhs > rhs { return .orderedDescending } diff --git a/Sources/Firelink/SidebarView.swift b/Sources/Firelink/SidebarView.swift index 331c6a1..f0cf9cf 100644 --- a/Sources/Firelink/SidebarView.swift +++ b/Sources/Firelink/SidebarView.swift @@ -1,11 +1,12 @@ import SwiftUI +import UniformTypeIdentifiers enum DownloadSidebarFilter: Hashable { case all case queued case active case completed - case failed + case unfinished case category(DownloadCategory) var title: String { @@ -14,7 +15,7 @@ enum DownloadSidebarFilter: Hashable { case .queued: "Queue" case .active: "Active" case .completed: "Completed" - case .failed: "Failed" + case .unfinished: "Unfinished" case .category(let category): category.rawValue } } @@ -30,6 +31,7 @@ struct SidebarView: View { @EnvironmentObject private var controller: DownloadController @Binding var selection: SidebarSelection @State private var queueBeingRenamed: DownloadQueue? + @State private var queueBeingRemoved: DownloadQueue? @State private var queueName = "" var body: some View { @@ -44,32 +46,20 @@ struct SidebarView: View { Label("Completed", systemImage: "checkmark.circle") .badge(controller.completedCount) .tag(SidebarSelection.downloads(.completed)) - Label("Failed", systemImage: "exclamationmark.triangle") - .badge(controller.failedCount) - .tag(SidebarSelection.downloads(.failed)) + Label("Unfinished", systemImage: "circle.dashed") + .badge(controller.unfinishedCount) + .tag(SidebarSelection.downloads(.unfinished)) } Section("Folders") { ForEach(DownloadCategory.allCases, id: \.self) { category in - Label(category.rawValue, systemImage: category.symbolName) - .badge(controller.downloads.filter { $0.category == category }.count) - .tag(SidebarSelection.downloads(.category(category))) + folderRow(for: 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 - } - } - } + queueRow(for: queue) } Button { @@ -101,6 +91,31 @@ struct SidebarView: View { queueBeingRenamed = nil } } + .confirmationDialog( + "Delete Queue", + isPresented: Binding( + get: { queueBeingRemoved != nil }, + set: { isPresented in + if !isPresented { + queueBeingRemoved = nil + } + } + ), + presenting: queueBeingRemoved + ) { queue in + Button("Delete Queue", role: .destructive) { + controller.removeQueue(id: queue.id) + if selection == .queue(queue.id) { + selection = .downloads(.unfinished) + } + queueBeingRemoved = nil + } + Button("Cancel", role: .cancel) { + queueBeingRemoved = nil + } + } message: { queue in + Text("Downloads in \(queue.name) will stay in All and Unfinished, but no longer belong to a queue.") + } .safeAreaInset(edge: .bottom) { VStack(spacing: 8) { Divider() @@ -126,4 +141,70 @@ struct SidebarView: View { .background(.bar) } } + + private func folderRow(for category: DownloadCategory) -> some View { + Label(category.rawValue, systemImage: category.symbolName) + .badge(controller.downloads.filter { $0.category == category }.count) + .tag(SidebarSelection.downloads(.category(category))) + } + + private func queueRow(for queue: DownloadQueue) -> some View { + Label(queue.name, systemImage: queue.isMain ? "list.bullet.rectangle" : "list.bullet") + .badge(controller.queueCount(for: queue.id)) + .tag(SidebarSelection.queue(queue.id)) + .onDrop( + of: [.text], + delegate: QueueSidebarDropDelegate( + queueID: queue.id, + selection: $selection, + controller: controller + ) + ) + .contextMenu { + if !queue.isMain { + Button("Rename") { + queueBeingRenamed = queue + queueName = queue.name + } + Button("Delete", role: .destructive) { + queueBeingRemoved = queue + } + } + } + } +} + +private struct QueueSidebarDropDelegate: DropDelegate { + let queueID: UUID + @Binding var selection: SidebarSelection + let controller: DownloadController + + func performDrop(info: DropInfo) -> Bool { + guard let provider = info.itemProviders(for: [.text]).first else { + return false + } + + provider.loadItem(forTypeIdentifier: UTType.text.identifier, options: nil) { item, _ in + guard let itemIDs = Self.itemIDs(from: item), !itemIDs.isEmpty else { return } + Task { @MainActor in + controller.assignToQueue(itemIDs: itemIDs, queueID: queueID) + selection = .queue(queueID) + } + } + return true + } + + nonisolated private static func itemIDs(from item: NSSecureCoding?) -> Set? { + let text: String? + if let data = item as? Data { + text = String(data: data, encoding: .utf8) + } else { + text = item as? String + } + + guard let text else { return nil } + return Set(text + .split(whereSeparator: { $0 == "\n" || $0 == "," || $0 == " " }) + .compactMap { UUID(uuidString: String($0)) }) + } }