feat(ui): implement multi-selection, shortcuts (Delete, Cmd+V, Cmd+A), custom status progress bar, and toolbar redesign

This commit is contained in:
nimbold
2026-06-01 08:45:06 +03:30
parent afb26378d7
commit d1bc8b4e6b
4 changed files with 187 additions and 80 deletions
+4
View File
@@ -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()
+82 -19
View File
@@ -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<DownloadItem.ID> = []
@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] {
@@ -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()
+100 -61
View File
@@ -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<DownloadItem.ID>
let title: String
@StateObject private var tableSettings = TableSettings()
@State private var pendingDeleteItem: DownloadItem?
@State private var pendingDeleteItems: Set<DownloadItem.ID>?
@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)
}
}