mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-28 11:37:17 +00:00
feat: add queue management
This commit is contained in:
@@ -11,6 +11,7 @@ struct AddDownloadsView: View {
|
|||||||
@State private var overrideDestination = false
|
@State private var overrideDestination = false
|
||||||
@State private var destinationPath = ""
|
@State private var destinationPath = ""
|
||||||
@State private var metadataTask: Task<Void, Never>?
|
@State private var metadataTask: Task<Void, Never>?
|
||||||
|
@State private var targetQueueID = DownloadQueue.mainQueueID
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -33,6 +34,8 @@ struct AddDownloadsView: View {
|
|||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
connectionsPerServer = Double(settings.perServerConnections)
|
connectionsPerServer = Double(settings.perServerConnections)
|
||||||
|
targetQueueID = controller.pendingAddQueueID ?? DownloadQueue.mainQueueID
|
||||||
|
controller.pendingAddQueueID = nil
|
||||||
if let text = controller.pendingPasteboardText {
|
if let text = controller.pendingPasteboardText {
|
||||||
linkText = text
|
linkText = text
|
||||||
controller.pendingPasteboardText = nil
|
controller.pendingPasteboardText = nil
|
||||||
@@ -290,7 +293,8 @@ struct AddDownloadsView: View {
|
|||||||
pendingDownloads,
|
pendingDownloads,
|
||||||
connectionsPerServer: Int(connectionsPerServer),
|
connectionsPerServer: Int(connectionsPerServer),
|
||||||
overrideDirectory: overrideDirectory,
|
overrideDirectory: overrideDirectory,
|
||||||
startImmediately: start
|
startImmediately: start,
|
||||||
|
queueID: targetQueueID
|
||||||
)
|
)
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,17 +22,38 @@ struct ContentView: View {
|
|||||||
switch sidebarSelection {
|
switch sidebarSelection {
|
||||||
case .downloads(let filter):
|
case .downloads(let filter):
|
||||||
downloadsView(filter: filter)
|
downloadsView(filter: filter)
|
||||||
|
case .queue(let queueID):
|
||||||
|
queueView(queueID: queueID)
|
||||||
case .settings:
|
case .settings:
|
||||||
SettingsView()
|
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 {
|
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) {
|
VStack(spacing: 0) {
|
||||||
DownloadTable(
|
DownloadTable(
|
||||||
items: filteredDownloads(for: filter),
|
items: items,
|
||||||
selection: $selection,
|
selection: $selection,
|
||||||
title: filter.title
|
title: title,
|
||||||
|
queueID: queueID
|
||||||
)
|
)
|
||||||
Divider()
|
Divider()
|
||||||
StatusBar()
|
StatusBar()
|
||||||
@@ -40,6 +61,7 @@ struct ContentView: View {
|
|||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
Button {
|
Button {
|
||||||
|
controller.pendingAddQueueID = queueID
|
||||||
openWindow(id: "add-downloads")
|
openWindow(id: "add-downloads")
|
||||||
} label: {
|
} label: {
|
||||||
Label("Add", systemImage: "plus")
|
Label("Add", systemImage: "plus")
|
||||||
@@ -48,7 +70,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
ToolbarItem {
|
ToolbarItem {
|
||||||
Button {
|
Button {
|
||||||
controller.startQueue()
|
controller.startQueue(queueID: queueID)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Start Queue", systemImage: "play.fill")
|
Label("Start Queue", systemImage: "play.fill")
|
||||||
}
|
}
|
||||||
@@ -94,13 +116,13 @@ struct ContentView: View {
|
|||||||
.opacity(0)
|
.opacity(0)
|
||||||
|
|
||||||
Button("") {
|
Button("") {
|
||||||
handlePaste()
|
handlePaste(queueID: queueID)
|
||||||
}
|
}
|
||||||
.keyboardShortcut("v", modifiers: .command)
|
.keyboardShortcut("v", modifiers: .command)
|
||||||
.opacity(0)
|
.opacity(0)
|
||||||
|
|
||||||
Button("") {
|
Button("") {
|
||||||
selectAll(filter: filter)
|
selectAll(items: items)
|
||||||
}
|
}
|
||||||
.keyboardShortcut("a", modifiers: .command)
|
.keyboardShortcut("a", modifiers: .command)
|
||||||
.opacity(0)
|
.opacity(0)
|
||||||
@@ -132,14 +154,15 @@ struct ContentView: View {
|
|||||||
selection.removeAll()
|
selection.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handlePaste() {
|
private func handlePaste(queueID: UUID?) {
|
||||||
guard let text = NSPasteboard.general.string(forType: .string), !text.isEmpty else { return }
|
guard let text = NSPasteboard.general.string(forType: .string), !text.isEmpty else { return }
|
||||||
controller.pendingPasteboardText = text
|
controller.pendingPasteboardText = text
|
||||||
|
controller.pendingAddQueueID = queueID
|
||||||
openWindow(id: "add-downloads")
|
openWindow(id: "add-downloads")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func selectAll(filter: DownloadSidebarFilter) {
|
private func selectAll(items: [DownloadItem]) {
|
||||||
selection = Set(filteredDownloads(for: filter).map { $0.id })
|
selection = Set(items.map { $0.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
private func filteredDownloads(for filter: DownloadSidebarFilter) -> [DownloadItem] {
|
private func filteredDownloads(for filter: DownloadSidebarFilter) -> [DownloadItem] {
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import Foundation
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class DownloadController: ObservableObject {
|
final class DownloadController: ObservableObject {
|
||||||
@Published var downloads: [DownloadItem] = []
|
@Published var downloads: [DownloadItem] = []
|
||||||
|
@Published var queues: [DownloadQueue] = [.main]
|
||||||
@Published var engineMessage = ""
|
@Published var engineMessage = ""
|
||||||
@Published var pendingPasteboardText: String?
|
@Published var pendingPasteboardText: String?
|
||||||
|
var pendingAddQueueID: UUID?
|
||||||
|
|
||||||
private let settings: AppSettings
|
private let settings: AppSettings
|
||||||
private let engine = Aria2DownloadEngine()
|
private let engine = Aria2DownloadEngine()
|
||||||
@@ -81,7 +83,7 @@ final class DownloadController: ObservableObject {
|
|||||||
Aria2DownloadEngine.findExecutable() != nil
|
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)),
|
guard let url = URL(string: urlText.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||||
let scheme = url.scheme?.lowercased(),
|
let scheme = url.scheme?.lowercased(),
|
||||||
["http", "https", "ftp", "sftp"].contains(scheme) else {
|
["http", "https", "ftp", "sftp"].contains(scheme) else {
|
||||||
@@ -97,7 +99,8 @@ final class DownloadController: ObservableObject {
|
|||||||
category: category,
|
category: category,
|
||||||
destinationDirectory: settings.destinationDirectory(for: category),
|
destinationDirectory: settings.destinationDirectory(for: category),
|
||||||
connectionsPerServer: min(max(connectionsPerServer ?? settings.perServerConnections, 1), 16),
|
connectionsPerServer: min(max(connectionsPerServer ?? settings.perServerConnections, 1), 16),
|
||||||
credentials: settings.credentials(for: url)
|
credentials: settings.credentials(for: url),
|
||||||
|
queueID: normalizedQueueID(queueID)
|
||||||
)
|
)
|
||||||
|
|
||||||
downloads.append(item)
|
downloads.append(item)
|
||||||
@@ -109,9 +112,11 @@ final class DownloadController: ObservableObject {
|
|||||||
_ pendingDownloads: [PendingDownload],
|
_ pendingDownloads: [PendingDownload],
|
||||||
connectionsPerServer: Int,
|
connectionsPerServer: Int,
|
||||||
overrideDirectory: URL?,
|
overrideDirectory: URL?,
|
||||||
startImmediately: Bool
|
startImmediately: Bool,
|
||||||
|
queueID: UUID = DownloadQueue.mainQueueID
|
||||||
) {
|
) {
|
||||||
let clampedConnections = min(max(connectionsPerServer, 1), 16)
|
let clampedConnections = min(max(connectionsPerServer, 1), 16)
|
||||||
|
let targetQueueID = normalizedQueueID(queueID)
|
||||||
|
|
||||||
let items = pendingDownloads.map { pending in
|
let items = pendingDownloads.map { pending in
|
||||||
DownloadItem(
|
DownloadItem(
|
||||||
@@ -123,7 +128,8 @@ final class DownloadController: ObservableObject {
|
|||||||
credentials: settings.credentials(for: pending.url),
|
credentials: settings.credentials(for: pending.url),
|
||||||
sizeBytes: pending.sizeBytes,
|
sizeBytes: pending.sizeBytes,
|
||||||
bytesText: ByteFormatter.string(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()
|
saveDownloads()
|
||||||
|
|
||||||
if startImmediately {
|
if startImmediately {
|
||||||
startQueue()
|
startQueue(queueID: targetQueueID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startQueue() {
|
func startQueue(queueID: UUID? = nil) {
|
||||||
engineMessage = ""
|
engineMessage = ""
|
||||||
restrictQueueToAutoResume = false
|
restrictQueueToAutoResume = false
|
||||||
markQueuedDownloadsForAutoResume()
|
markQueuedDownloadsForAutoResume(queueID: queueID)
|
||||||
pumpQueue()
|
pumpQueue()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,6 +234,60 @@ final class DownloadController: ObservableObject {
|
|||||||
saveDownloads()
|
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() {
|
private func pumpQueue() {
|
||||||
guard hasAria2 else {
|
guard hasAria2 else {
|
||||||
engineMessage = "aria2c is not installed. Run `brew install aria2` to enable downloads."
|
engineMessage = "aria2c is not installed. Run `brew install aria2` to enable downloads."
|
||||||
@@ -343,8 +403,10 @@ final class DownloadController: ObservableObject {
|
|||||||
saveDownloads()
|
saveDownloads()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func markQueuedDownloadsForAutoResume() {
|
private func markQueuedDownloadsForAutoResume(queueID: UUID?) {
|
||||||
for index in downloads.indices where downloads[index].status == .queued {
|
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
|
downloads[index].autoResumeOnLaunch = true
|
||||||
}
|
}
|
||||||
saveDownloads()
|
saveDownloads()
|
||||||
@@ -431,7 +493,8 @@ final class DownloadController: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
let directory = storageURL.deletingLastPathComponent()
|
let directory = storageURL.deletingLastPathComponent()
|
||||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
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)
|
try data.write(to: storageURL, options: .atomic)
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to save downloads: \(error)")
|
print("Failed to save downloads: \(error)")
|
||||||
@@ -442,11 +505,21 @@ final class DownloadController: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
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 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
|
var shouldResumeRecoveredDownloads = false
|
||||||
self.downloads = loaded.map { item in
|
self.queues = normalizedQueues(state.queues)
|
||||||
|
self.downloads = state.downloads.map { item in
|
||||||
var adjusted = item
|
var adjusted = item
|
||||||
|
adjusted.queueID = normalizedQueueID(adjusted.queueID)
|
||||||
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."
|
||||||
@@ -471,6 +544,31 @@ final class DownloadController: ObservableObject {
|
|||||||
return false
|
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 {
|
private final class SleepActivityHandle: @unchecked Sendable {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
enum DownloadColumn: String, CaseIterable, Identifiable, Codable {
|
enum DownloadColumn: String, CaseIterable, Identifiable, Codable {
|
||||||
|
case priority = "#"
|
||||||
case fileName = "File name"
|
case fileName = "File name"
|
||||||
case size = "Size"
|
case size = "Size"
|
||||||
case progress = "Progress"
|
case progress = "Progress"
|
||||||
@@ -21,6 +23,7 @@ enum DownloadColumn: String, CaseIterable, Identifiable, Codable {
|
|||||||
|
|
||||||
var width: CGFloat {
|
var width: CGFloat {
|
||||||
switch self {
|
switch self {
|
||||||
|
case .priority: return 58
|
||||||
case .fileName: return 340
|
case .fileName: return 340
|
||||||
case .size: return 100
|
case .size: return 100
|
||||||
case .status: return 105
|
case .status: return 105
|
||||||
@@ -104,11 +107,13 @@ struct DownloadTable: View {
|
|||||||
let items: [DownloadItem]
|
let items: [DownloadItem]
|
||||||
@Binding var selection: Set<DownloadItem.ID>
|
@Binding var selection: Set<DownloadItem.ID>
|
||||||
let title: String
|
let title: String
|
||||||
|
var queueID: UUID?
|
||||||
|
|
||||||
@StateObject private var tableSettings = TableSettings()
|
@StateObject private var tableSettings = TableSettings()
|
||||||
@State private var pendingDeleteItems: Set<DownloadItem.ID>?
|
@State private var pendingDeleteItems: Set<DownloadItem.ID>?
|
||||||
@State private var resizeBaseWidths: [DownloadColumn: CGFloat] = [:]
|
@State private var resizeBaseWidths: [DownloadColumn: CGFloat] = [:]
|
||||||
@State private var lastSelectedIndex: Int?
|
@State private var lastSelectedIndex: Int?
|
||||||
|
@State private var draggedItemID: DownloadItem.ID?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -201,6 +206,7 @@ struct DownloadTable: View {
|
|||||||
private func tableRow(for item: DownloadItem, tableWidth: CGFloat, trailingWidth: CGFloat) -> some View {
|
private func tableRow(for item: DownloadItem, tableWidth: CGFloat, trailingWidth: CGFloat) -> some View {
|
||||||
DownloadRow(
|
DownloadRow(
|
||||||
item: item,
|
item: item,
|
||||||
|
priorityNumber: priorityNumber(for: item),
|
||||||
visibleColumns: orderedVisibleColumns,
|
visibleColumns: orderedVisibleColumns,
|
||||||
columnWidth: { width(for: $0) },
|
columnWidth: { width(for: $0) },
|
||||||
trailingWidth: trailingWidth
|
trailingWidth: trailingWidth
|
||||||
@@ -231,39 +237,26 @@ struct DownloadTable: View {
|
|||||||
.contextMenu {
|
.contextMenu {
|
||||||
rowContextMenu(for: item)
|
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 {
|
private func tableHeader(trailingWidth: CGFloat) -> some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
ForEach(orderedVisibleColumns) { column in
|
ForEach(orderedVisibleColumns) { column in
|
||||||
ZStack(alignment: .trailing) {
|
ZStack(alignment: .trailing) {
|
||||||
Button {
|
headerContent(for: column)
|
||||||
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)
|
|
||||||
|
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(.secondary.opacity(0.18))
|
.fill(.secondary.opacity(0.18))
|
||||||
@@ -301,7 +294,7 @@ struct DownloadTable: View {
|
|||||||
.background(.bar)
|
.background(.bar)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
Section("Columns") {
|
Section("Columns") {
|
||||||
ForEach(DownloadColumn.allCases) { column in
|
ForEach(availableColumns) { column in
|
||||||
Toggle(column.rawValue, isOn: Binding(
|
Toggle(column.rawValue, isOn: Binding(
|
||||||
get: { tableSettings.visibleColumns.contains(column) },
|
get: { tableSettings.visibleColumns.contains(column) },
|
||||||
set: { isVisible in
|
set: { isVisible in
|
||||||
@@ -314,10 +307,12 @@ struct DownloadTable: View {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Section("Sort By") {
|
if queueID == nil {
|
||||||
ForEach(DownloadColumn.allCases) { column in
|
Section("Sort By") {
|
||||||
Button(column.rawValue) {
|
ForEach(availableColumns) { column in
|
||||||
tableSettings.sortColumn = column
|
Button(column.rawValue) {
|
||||||
|
tableSettings.sortColumn = column
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -386,7 +381,17 @@ struct DownloadTable: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var orderedVisibleColumns: [DownloadColumn] {
|
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 {
|
private var totalWidth: CGFloat {
|
||||||
@@ -398,7 +403,11 @@ struct DownloadTable: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var sortedItems: [DownloadItem] {
|
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)
|
let result = compare(lhs, rhs, by: tableSettings.sortColumn)
|
||||||
if result == .orderedSame {
|
if result == .orderedSame {
|
||||||
return lhs.id.uuidString < rhs.id.uuidString
|
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 {
|
private func compare(_ lhs: DownloadItem, _ rhs: DownloadItem, by column: DownloadColumn) -> ComparisonResult {
|
||||||
switch column {
|
switch column {
|
||||||
|
case .priority:
|
||||||
|
return compare(priorityNumber(for: lhs) ?? 0, priorityNumber(for: rhs) ?? 0)
|
||||||
case .fileName: return lhs.fileName.localizedCaseInsensitiveCompare(rhs.fileName)
|
case .fileName: return lhs.fileName.localizedCaseInsensitiveCompare(rhs.fileName)
|
||||||
case .size: return compare(lhs.sizeBytes ?? -1, rhs.sizeBytes ?? -1)
|
case .size: return compare(lhs.sizeBytes ?? -1, rhs.sizeBytes ?? -1)
|
||||||
case .status: return compare(lhs.status.rawValue, rhs.status.rawValue)
|
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<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 }
|
||||||
@@ -456,6 +512,7 @@ struct DownloadTable: View {
|
|||||||
|
|
||||||
private struct DownloadRow: View {
|
private struct DownloadRow: View {
|
||||||
let item: DownloadItem
|
let item: DownloadItem
|
||||||
|
let priorityNumber: Int?
|
||||||
let visibleColumns: [DownloadColumn]
|
let visibleColumns: [DownloadColumn]
|
||||||
let columnWidth: (DownloadColumn) -> CGFloat
|
let columnWidth: (DownloadColumn) -> CGFloat
|
||||||
let trailingWidth: CGFloat
|
let trailingWidth: CGFloat
|
||||||
@@ -480,6 +537,11 @@ private struct DownloadRow: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func cell(for column: DownloadColumn) -> some View {
|
private func cell(for column: DownloadColumn) -> some View {
|
||||||
switch column {
|
switch column {
|
||||||
|
case .priority:
|
||||||
|
Text(priorityNumber.map(String.init) ?? "-")
|
||||||
|
.monospacedDigit()
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
case .fileName:
|
case .fileName:
|
||||||
HStack(alignment: .top, spacing: 8) {
|
HStack(alignment: .top, spacing: 8) {
|
||||||
Image(systemName: item.category.symbolName)
|
Image(systemName: item.category.symbolName)
|
||||||
@@ -610,3 +672,25 @@ private struct DownloadRow: View {
|
|||||||
return date.formatted(date: .abbreviated, time: .shortened)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
struct DownloadCredentials: Codable, Equatable, Sendable {
|
||||||
var username: String
|
var username: String
|
||||||
var password: String
|
var password: String
|
||||||
@@ -58,6 +73,7 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
|
|||||||
var createdAt = Date()
|
var createdAt = Date()
|
||||||
var lastTryAt: Date?
|
var lastTryAt: Date?
|
||||||
var autoResumeOnLaunch: Bool?
|
var autoResumeOnLaunch: Bool?
|
||||||
|
var queueID: UUID?
|
||||||
|
|
||||||
var destinationPath: String {
|
var destinationPath: String {
|
||||||
destinationDirectory.appendingPathComponent(fileName).path
|
destinationDirectory.appendingPathComponent(fileName).path
|
||||||
|
|||||||
@@ -22,12 +22,15 @@ enum DownloadSidebarFilter: Hashable {
|
|||||||
|
|
||||||
enum SidebarSelection: Hashable {
|
enum SidebarSelection: Hashable {
|
||||||
case downloads(DownloadSidebarFilter)
|
case downloads(DownloadSidebarFilter)
|
||||||
|
case queue(UUID)
|
||||||
case settings
|
case settings
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SidebarView: View {
|
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 queueName = ""
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List(selection: $selection) {
|
List(selection: $selection) {
|
||||||
@@ -35,9 +38,6 @@ struct SidebarView: View {
|
|||||||
Label("All", systemImage: "tray.full")
|
Label("All", systemImage: "tray.full")
|
||||||
.badge(controller.downloads.count)
|
.badge(controller.downloads.count)
|
||||||
.tag(SidebarSelection.downloads(.all))
|
.tag(SidebarSelection.downloads(.all))
|
||||||
Label("Queue", systemImage: "list.bullet")
|
|
||||||
.badge(controller.queuedCount)
|
|
||||||
.tag(SidebarSelection.downloads(.queued))
|
|
||||||
Label("Active", systemImage: "bolt.fill")
|
Label("Active", systemImage: "bolt.fill")
|
||||||
.badge(controller.activeCount)
|
.badge(controller.activeCount)
|
||||||
.tag(SidebarSelection.downloads(.active))
|
.tag(SidebarSelection.downloads(.active))
|
||||||
@@ -56,8 +56,51 @@ struct SidebarView: View {
|
|||||||
.tag(SidebarSelection.downloads(.category(category)))
|
.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)
|
.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) {
|
.safeAreaInset(edge: .bottom) {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
Divider()
|
Divider()
|
||||||
|
|||||||
Reference in New Issue
Block a user