feat: improve queue management

This commit is contained in:
nimbold
2026-06-02 05:12:23 +03:30
parent ac039a50c8
commit fd6457ddc5
4 changed files with 174 additions and 33 deletions
+2 -2
View File
@@ -175,8 +175,8 @@ struct ContentView: View {
controller.downloads.filter { $0.status == .downloading } controller.downloads.filter { $0.status == .downloading }
case .completed: case .completed:
controller.downloads.filter { $0.status == .completed } controller.downloads.filter { $0.status == .completed }
case .failed: case .unfinished:
controller.downloads.filter { $0.status == .failed } controller.downloads.filter { $0.status != .completed }
case .category(let category): case .category(let category):
controller.downloads.filter { $0.category == category } controller.downloads.filter { $0.category == category }
} }
+55 -7
View File
@@ -75,8 +75,8 @@ final class DownloadController: ObservableObject {
downloads.filter { $0.status == .completed }.count downloads.filter { $0.status == .completed }.count
} }
var failedCount: Int { var unfinishedCount: Int {
downloads.filter { $0.status == .failed }.count downloads.filter { $0.status != .completed }.count
} }
var hasAria2: Bool { var hasAria2: Bool {
@@ -182,6 +182,30 @@ final class DownloadController: ObservableObject {
updateSleepActivity() updateSleepActivity()
} }
func assignToQueue(itemIDs: Set<UUID>, 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) { func resume(_ item: DownloadItem) {
restrictQueueToAutoResume = false restrictQueueToAutoResume = false
update(item.id) { update(item.id) {
@@ -240,7 +264,7 @@ final class DownloadController: ObservableObject {
func queueItems(for id: UUID) -> [DownloadItem] { func queueItems(for id: UUID) -> [DownloadItem] {
let id = normalizedQueueID(id) let id = normalizedQueueID(id)
return downloads.filter { normalizedQueueID($0.queueID) == id } return downloads.filter { validQueueID($0.queueID) == id }
} }
func queueCount(for id: UUID) -> Int { func queueCount(for id: UUID) -> Int {
@@ -275,11 +299,25 @@ final class DownloadController: ObservableObject {
saveDownloads() 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) { func moveDownload(_ itemID: UUID, before targetID: UUID, in queueID: UUID) {
let queueID = normalizedQueueID(queueID) let queueID = normalizedQueueID(queueID)
guard itemID != targetID, guard itemID != targetID,
let source = downloads.firstIndex(where: { $0.id == itemID && normalizedQueueID($0.queueID) == queueID }), let source = downloads.firstIndex(where: { $0.id == itemID && validQueueID($0.queueID) == queueID }),
let target = downloads.firstIndex(where: { $0.id == targetID && normalizedQueueID($0.queueID) == queueID }) else { let target = downloads.firstIndex(where: { $0.id == targetID && validQueueID($0.queueID) == queueID }) else {
return return
} }
@@ -506,20 +544,26 @@ final class DownloadController: ObservableObject {
guard FileManager.default.fileExists(atPath: storageURL.path) else { return false } guard FileManager.default.fileExists(atPath: storageURL.path) else { return false }
let data = try Data(contentsOf: storageURL) let data = try Data(contentsOf: storageURL)
let state: StoredDownloadState let state: StoredDownloadState
let isLegacyDownloadList: Bool
if let storedState = try? JSONDecoder().decode(StoredDownloadState.self, from: data) { if let storedState = try? JSONDecoder().decode(StoredDownloadState.self, from: data) {
state = storedState state = storedState
isLegacyDownloadList = false
} else { } else {
state = StoredDownloadState( state = StoredDownloadState(
queues: [.main], queues: [.main],
downloads: try JSONDecoder().decode([DownloadItem].self, from: data) downloads: try JSONDecoder().decode([DownloadItem].self, from: data)
) )
isLegacyDownloadList = true
} }
var shouldResumeRecoveredDownloads = false var shouldResumeRecoveredDownloads = false
self.queues = normalizedQueues(state.queues) self.queues = normalizedQueues(state.queues)
self.downloads = state.downloads.map { item in self.downloads = state.downloads.map { item in
var adjusted = item 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 { if adjusted.status == .downloading {
adjusted.status = .queued adjusted.status = .queued
adjusted.message = "Recovered after restart. Resuming from partial file." adjusted.message = "Recovered after restart. Resuming from partial file."
@@ -546,8 +590,12 @@ final class DownloadController: ObservableObject {
} }
private func normalizedQueueID(_ id: UUID?) -> UUID { 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 { guard let id, queues.contains(where: { $0.id == id }) else {
return DownloadQueue.mainQueueID return nil
} }
return id return id
} }
+17 -5
View File
@@ -239,7 +239,7 @@ struct DownloadTable: View {
} }
.onDrag { .onDrag {
draggedItemID = item.id draggedItemID = item.id
return NSItemProvider(object: item.id.uuidString as NSString) return NSItemProvider(object: dragPayload(for: item) as NSString)
} }
.onDrop( .onDrop(
of: [.text], of: [.text],
@@ -361,10 +361,15 @@ struct DownloadTable: View {
} }
} }
if targetItems.contains(where: { $0.status != .downloading && $0.status != .queued }) { if targetItems.contains(where: { $0.status != .completed && $0.status != .downloading }) {
Button { Menu {
for target in targetItems where target.status != .downloading && target.status != .queued { ForEach(controller.queues) { queue in
controller.queue(target) Button(queue.name) {
controller.assignToQueue(
itemIDs: Set(targetItems.map(\.id)),
queueID: queue.id
)
}
} }
} label: { } label: {
Label("Add to Queue", systemImage: "list.bullet") Label("Add to Queue", systemImage: "list.bullet")
@@ -482,6 +487,13 @@ struct DownloadTable: View {
return index + 1 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<T: Comparable>(_ lhs: T, _ rhs: T) -> ComparisonResult { private func compare<T: Comparable>(_ lhs: T, _ rhs: T) -> ComparisonResult {
if lhs < rhs { return .orderedAscending } if lhs < rhs { return .orderedAscending }
if lhs > rhs { return .orderedDescending } if lhs > rhs { return .orderedDescending }
+100 -19
View File
@@ -1,11 +1,12 @@
import SwiftUI import SwiftUI
import UniformTypeIdentifiers
enum DownloadSidebarFilter: Hashable { enum DownloadSidebarFilter: Hashable {
case all case all
case queued case queued
case active case active
case completed case completed
case failed case unfinished
case category(DownloadCategory) case category(DownloadCategory)
var title: String { var title: String {
@@ -14,7 +15,7 @@ enum DownloadSidebarFilter: Hashable {
case .queued: "Queue" case .queued: "Queue"
case .active: "Active" case .active: "Active"
case .completed: "Completed" case .completed: "Completed"
case .failed: "Failed" case .unfinished: "Unfinished"
case .category(let category): category.rawValue case .category(let category): category.rawValue
} }
} }
@@ -30,6 +31,7 @@ struct SidebarView: View {
@EnvironmentObject private var controller: DownloadController @EnvironmentObject private var controller: DownloadController
@Binding var selection: SidebarSelection @Binding var selection: SidebarSelection
@State private var queueBeingRenamed: DownloadQueue? @State private var queueBeingRenamed: DownloadQueue?
@State private var queueBeingRemoved: DownloadQueue?
@State private var queueName = "" @State private var queueName = ""
var body: some View { var body: some View {
@@ -44,32 +46,20 @@ struct SidebarView: View {
Label("Completed", systemImage: "checkmark.circle") Label("Completed", systemImage: "checkmark.circle")
.badge(controller.completedCount) .badge(controller.completedCount)
.tag(SidebarSelection.downloads(.completed)) .tag(SidebarSelection.downloads(.completed))
Label("Failed", systemImage: "exclamationmark.triangle") Label("Unfinished", systemImage: "circle.dashed")
.badge(controller.failedCount) .badge(controller.unfinishedCount)
.tag(SidebarSelection.downloads(.failed)) .tag(SidebarSelection.downloads(.unfinished))
} }
Section("Folders") { Section("Folders") {
ForEach(DownloadCategory.allCases, id: \.self) { category in ForEach(DownloadCategory.allCases, id: \.self) { category in
Label(category.rawValue, systemImage: category.symbolName) folderRow(for: category)
.badge(controller.downloads.filter { $0.category == category }.count)
.tag(SidebarSelection.downloads(.category(category)))
} }
} }
Section("Queues") { Section("Queues") {
ForEach(controller.queues) { queue in ForEach(controller.queues) { queue in
Label(queue.name, systemImage: queue.isMain ? "list.bullet.rectangle" : "list.bullet") queueRow(for: queue)
.badge(controller.queueCount(for: queue.id))
.tag(SidebarSelection.queue(queue.id))
.contextMenu {
if !queue.isMain {
Button("Rename") {
queueBeingRenamed = queue
queueName = queue.name
}
}
}
} }
Button { Button {
@@ -101,6 +91,31 @@ struct SidebarView: View {
queueBeingRenamed = nil 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) { .safeAreaInset(edge: .bottom) {
VStack(spacing: 8) { VStack(spacing: 8) {
Divider() Divider()
@@ -126,4 +141,70 @@ struct SidebarView: View {
.background(.bar) .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<UUID>? {
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)) })
}
} }