From d1bc8b4e6bd5d6f08ff0a7453345109c91737768 Mon Sep 17 00:00:00 2001 From: nimbold <11913706+nimbold@users.noreply.github.com> Date: Mon, 1 Jun 2026 08:45:06 +0330 Subject: [PATCH] feat(ui): implement multi-selection, shortcuts (Delete, Cmd+V, Cmd+A), custom status progress bar, and toolbar redesign --- Sources/Firelink/AddDownloadsView.swift | 4 + Sources/Firelink/ContentView.swift | 101 +++++++++++--- Sources/Firelink/DownloadController.swift | 1 + Sources/Firelink/DownloadTable.swift | 161 ++++++++++++++-------- 4 files changed, 187 insertions(+), 80 deletions(-) diff --git a/Sources/Firelink/AddDownloadsView.swift b/Sources/Firelink/AddDownloadsView.swift index 9cbce79..c0becbc 100644 --- a/Sources/Firelink/AddDownloadsView.swift +++ b/Sources/Firelink/AddDownloadsView.swift @@ -33,6 +33,10 @@ struct AddDownloadsView: View { } .onAppear { connectionsPerServer = Double(settings.perServerConnections) + if let text = controller.pendingPasteboardText { + linkText = text + controller.pendingPasteboardText = nil + } } .onDisappear { metadataTask?.cancel() diff --git a/Sources/Firelink/ContentView.swift b/Sources/Firelink/ContentView.swift index 0a29c82..a262df9 100644 --- a/Sources/Firelink/ContentView.swift +++ b/Sources/Firelink/ContentView.swift @@ -4,8 +4,9 @@ import SwiftUI struct ContentView: View { @EnvironmentObject private var controller: DownloadController @Environment(\.openWindow) private var openWindow - @State private var selection: DownloadItem.ID? + @State private var selection: Set = [] @State private var sidebarFilter: DownloadSidebarFilter = .all + @State private var showDeleteConfirmation = false var body: some View { NavigationSplitView { @@ -22,53 +23,115 @@ struct ContentView: View { StatusBar() } .toolbar { - ToolbarItemGroup { + ToolbarItem(placement: .primaryAction) { Button { openWindow(id: "add-downloads") } label: { Label("Add", systemImage: "plus") } - .buttonStyle(.borderedProminent) - + } + + ToolbarItem { Button { controller.startQueue() } label: { Label("Start Queue", systemImage: "play.fill") } + } - SettingsLink { - Label("Settings", systemImage: "gearshape") - } - - if let selectedItem { - if selectedItem.status == .downloading { + ToolbarItemGroup { + if !selectedItems.isEmpty { + if selectedItems.contains(where: { $0.status == .downloading }) { Button { - controller.pause(selectedItem) + for item in selectedItems where item.status == .downloading { + controller.pause(item) + } } label: { Label("Stop", systemImage: "stop.fill") } - } else if selectedItem.status == .paused || selectedItem.status == .failed || selectedItem.status == .canceled { + } + + if selectedItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled }) { Button { - controller.resume(selectedItem) + for item in selectedItems where item.status == .paused || item.status == .failed || item.status == .canceled { + controller.resume(item) + } } label: { - Label("Resume", systemImage: "arrow.clockwise") + Label("Start", systemImage: "play.fill") } } Button(role: .destructive) { - controller.cancel(selectedItem) + showDeleteConfirmation = true } label: { - Label("Cancel", systemImage: "xmark") + Label("Delete", systemImage: "trash") } } } + + ToolbarItem { + SettingsLink { + Label("Settings", systemImage: "gearshape") + } + } + } + .background { + Button("") { + if !selection.isEmpty { + showDeleteConfirmation = true + } + } + .keyboardShortcut(.delete, modifiers: []) + .opacity(0) + + Button("") { + handlePaste() + } + .keyboardShortcut("v", modifiers: .command) + .opacity(0) + + Button("") { + selectAll() + } + .keyboardShortcut("a", modifiers: .command) + .opacity(0) + } + .confirmationDialog( + "Delete \(selection.count) Download\(selection.count == 1 ? "" : "s")", + isPresented: $showDeleteConfirmation + ) { + Button("Remove from List") { + deleteSelected(deleteFiles: false) + } + Button("Move Files and Cache to Trash", role: .destructive) { + deleteSelected(deleteFiles: true) + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Are you sure you want to delete the selected downloads?") } } } - private var selectedItem: DownloadItem? { - guard let selection else { return nil } - return controller.downloads.first(where: { $0.id == selection }) + private var selectedItems: [DownloadItem] { + controller.downloads.filter { selection.contains($0.id) } + } + + private func deleteSelected(deleteFiles: Bool) { + for item in selectedItems { + controller.delete(item, deleteFiles: deleteFiles) + } + selection.removeAll() + } + + private func handlePaste() { + guard let text = NSPasteboard.general.string(forType: .string), !text.isEmpty else { return } + controller.pendingPasteboardText = text + openWindow(id: "add-downloads") + } + + private func selectAll() { + selection = Set(filteredDownloads.map { $0.id }) } private var filteredDownloads: [DownloadItem] { diff --git a/Sources/Firelink/DownloadController.swift b/Sources/Firelink/DownloadController.swift index ea48ea9..9c72a7b 100644 --- a/Sources/Firelink/DownloadController.swift +++ b/Sources/Firelink/DownloadController.swift @@ -6,6 +6,7 @@ import Foundation final class DownloadController: ObservableObject { @Published var downloads: [DownloadItem] = [] @Published var engineMessage = "" + @Published var pendingPasteboardText: String? private let settings: AppSettings private let engine = Aria2DownloadEngine() diff --git a/Sources/Firelink/DownloadTable.swift b/Sources/Firelink/DownloadTable.swift index c8da7eb..3d7a247 100644 --- a/Sources/Firelink/DownloadTable.swift +++ b/Sources/Firelink/DownloadTable.swift @@ -102,12 +102,13 @@ struct DownloadTable: View { @EnvironmentObject private var settings: AppSettings @Environment(\.openWindow) private var openWindow let items: [DownloadItem] - @Binding var selection: DownloadItem.ID? + @Binding var selection: Set let title: String @StateObject private var tableSettings = TableSettings() - @State private var pendingDeleteItem: DownloadItem? + @State private var pendingDeleteItems: Set? @State private var resizeBaseWidths: [DownloadColumn: CGFloat] = [:] + @State private var lastSelectedIndex: Int? var body: some View { VStack(spacing: 0) { @@ -161,33 +162,38 @@ struct DownloadTable: View { .confirmationDialog( "Delete Download", isPresented: Binding( - get: { pendingDeleteItem != nil }, + get: { pendingDeleteItems != nil }, set: { isPresented in if !isPresented { - pendingDeleteItem = nil + pendingDeleteItems = nil } } ), - presenting: pendingDeleteItem - ) { item in + presenting: pendingDeleteItems + ) { ids in Button("Remove from List") { - controller.delete(item, deleteFiles: false) - pendingDeleteItem = nil + let items = controller.downloads.filter { ids.contains($0.id) } + for item in items { controller.delete(item, deleteFiles: false) } + selection.subtract(ids) + pendingDeleteItems = nil } Button("Move File and Cache to Trash", role: .destructive) { - controller.delete(item, deleteFiles: true) - pendingDeleteItem = nil + let items = controller.downloads.filter { ids.contains($0.id) } + for item in items { controller.delete(item, deleteFiles: true) } + selection.subtract(ids) + pendingDeleteItems = nil } Button("Cancel", role: .cancel) { - pendingDeleteItem = nil + pendingDeleteItems = nil } - } message: { item in - if item.status == .completed { - Text("Remove this download from Firelink, or also move the downloaded file to Trash.") + } message: { ids in + let items = controller.downloads.filter { ids.contains($0.id) } + if items.allSatisfy({ $0.status == .completed }) { + Text("Remove \(items.count == 1 ? "this download" : "these \(items.count) downloads") from Firelink, or also move the downloaded files to Trash.") } else { - Text("Remove this unfinished download from Firelink. Partial cache files are removed automatically; moving to Trash also sends any partial file there.") + Text("Remove \(items.count == 1 ? "this download" : "these \(items.count) downloads") from Firelink. Partial cache files are removed automatically; moving to Trash also sends any partial files there.") } } } @@ -201,10 +207,26 @@ struct DownloadTable: View { ) .id(item.id) .frame(width: tableWidth, alignment: .leading) - .background(selection == item.id ? Color.accentColor.opacity(0.12) : Color.clear) + .background(selection.contains(item.id) ? Color.accentColor.opacity(0.12) : Color.clear) .contentShape(Rectangle()) .onTapGesture { - selection = item.id + let index = sortedItems.firstIndex(where: { $0.id == item.id }) + + if NSEvent.modifierFlags.contains(.command) { + if selection.contains(item.id) { + selection.remove(item.id) + } else { + selection.insert(item.id) + } + lastSelectedIndex = index + } else if NSEvent.modifierFlags.contains(.shift), let lastIndex = lastSelectedIndex, let currentIndex = index { + let range = min(lastIndex, currentIndex)...max(lastIndex, currentIndex) + let rangeIds = range.map { sortedItems[$0].id } + selection.formUnion(rangeIds) + } else { + selection = [item.id] + lastSelectedIndex = index + } } .contextMenu { rowContextMenu(for: item) @@ -304,45 +326,60 @@ struct DownloadTable: View { @ViewBuilder private func rowContextMenu(for item: DownloadItem) -> some View { + let targetItems = selection.contains(item.id) ? controller.downloads.filter { selection.contains($0.id) } : [item] + Button { - openWindow(value: item.id) + for target in targetItems { + openWindow(value: target.id) + } } label: { - Label("Properties", systemImage: "info.circle") + Label(targetItems.count > 1 ? "Properties (\(targetItems.count))" : "Properties", systemImage: "info.circle") } Button { - showInFinder(item) + for target in targetItems { + showInFinder(target) + } } label: { - Label("Show in Finder", systemImage: "finder") + Label(targetItems.count > 1 ? "Show in Finder (\(targetItems.count))" : "Show in Finder", systemImage: "finder") } Divider() - Button { - controller.resume(item) - } label: { - Label("Resume", systemImage: "arrow.clockwise") + if targetItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled }) { + Button { + for target in targetItems where target.status == .paused || target.status == .failed || target.status == .canceled { + controller.resume(target) + } + } label: { + Label("Start", systemImage: "play.fill") + } } - .disabled(item.status == .downloading) - Button { - controller.pause(item) - } label: { - Label("Stop", systemImage: "stop.fill") + if targetItems.contains(where: { $0.status == .downloading }) { + Button { + for target in targetItems where target.status == .downloading { + controller.pause(target) + } + } label: { + Label("Stop", systemImage: "stop.fill") + } } - .disabled(item.status != .downloading) - Button { - controller.queue(item) - } label: { - Label("Add to Queue", systemImage: "list.bullet") + 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) + } + } label: { + Label("Add to Queue", systemImage: "list.bullet") + } } - .disabled(item.status == .downloading || item.status == .queued) Divider() Button(role: .destructive) { - pendingDeleteItem = item + pendingDeleteItems = Set(targetItems.map(\.id)) } label: { Label("Delete", systemImage: "trash") } @@ -467,16 +504,8 @@ private struct DownloadRow: View { .monospacedDigit() .lineLimit(1) .truncationMode(.tail) - case .status: - Text(item.status.rawValue) - .lineLimit(1) - .truncationMode(.tail) - case .progress: - Text(progressStatusText) - .monospacedDigit() - .foregroundStyle(statusColor) - .lineLimit(1) - .truncationMode(.tail) + case .status, .progress: + progressBarCell() case .lastTry: Text(formatted(item.lastTryAt)) .lineLimit(1) @@ -549,20 +578,30 @@ private struct DownloadRow: View { } } - private var progressStatusText: String { - switch item.status { - case .completed: - return "Completed" - case .failed: - return "Failed" - case .canceled: - return "Canceled" - case .paused: - return "Paused \(item.progress.formatted(.percent.precision(.fractionLength(0))))" - case .queued: - return "Queued" - case .downloading: - return item.progress.formatted(.percent.precision(.fractionLength(0))) + @ViewBuilder + private func progressBarCell() -> some View { + if item.status == .completed { + Text("Completed") + .foregroundStyle(.green) + .lineLimit(1) + .truncationMode(.tail) + } else { + GeometryReader { proxy in + ZStack { + RoundedRectangle(cornerRadius: 4) + .fill(Color.secondary.opacity(0.15)) + + RoundedRectangle(cornerRadius: 4) + .fill(statusColor) + .frame(width: max(0, proxy.size.width * item.progress)) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(item.progress.formatted(.percent.precision(.fractionLength(0)))) + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundColor(.primary) + } + } + .frame(height: 16) } }