mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-27 19:17:13 +00:00
feat: improve download table layout
This commit is contained in:
@@ -156,8 +156,8 @@ private struct SidebarView: View {
|
|||||||
enum DownloadColumn: String, CaseIterable, Identifiable {
|
enum DownloadColumn: String, CaseIterable, Identifiable {
|
||||||
case fileName = "File name"
|
case fileName = "File name"
|
||||||
case size = "Size"
|
case size = "Size"
|
||||||
case status = "Status"
|
|
||||||
case progress = "Progress"
|
case progress = "Progress"
|
||||||
|
case status = "Status"
|
||||||
case lastTry = "Last try date"
|
case lastTry = "Last try date"
|
||||||
case dateAdded = "Date added"
|
case dateAdded = "Date added"
|
||||||
case category = "Category"
|
case category = "Category"
|
||||||
@@ -173,10 +173,10 @@ enum DownloadColumn: String, CaseIterable, Identifiable {
|
|||||||
|
|
||||||
var width: CGFloat {
|
var width: CGFloat {
|
||||||
switch self {
|
switch self {
|
||||||
case .fileName: 320
|
case .fileName: 340
|
||||||
case .size: 100
|
case .size: 100
|
||||||
case .status: 105
|
case .status: 105
|
||||||
case .progress: 95
|
case .progress: 115
|
||||||
case .lastTry, .dateAdded: 155
|
case .lastTry, .dateAdded: 155
|
||||||
case .category: 105
|
case .category: 105
|
||||||
case .connections, .liveConnections: 95
|
case .connections, .liveConnections: 95
|
||||||
@@ -206,10 +206,14 @@ private struct DownloadTable: View {
|
|||||||
let title: String
|
let title: String
|
||||||
|
|
||||||
@State private var visibleColumns: Set<DownloadColumn> = [
|
@State private var visibleColumns: Set<DownloadColumn> = [
|
||||||
.fileName, .size, .status, .progress, .lastTry, .dateAdded, .speed, .eta, .message
|
.fileName, .size, .progress, .eta, .lastTry, .dateAdded
|
||||||
]
|
]
|
||||||
|
@State private var columnWidths = Dictionary(
|
||||||
|
uniqueKeysWithValues: DownloadColumn.allCases.map { ($0, $0.width) }
|
||||||
|
)
|
||||||
@State private var sortColumn: DownloadColumn = .dateAdded
|
@State private var sortColumn: DownloadColumn = .dateAdded
|
||||||
@State private var sortDirection: SortDirection = .descending
|
@State private var sortDirection: SortDirection = .descending
|
||||||
|
@State private var pendingDeleteItem: DownloadItem?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -232,7 +236,11 @@ private struct DownloadTable: View {
|
|||||||
ScrollView([.horizontal, .vertical]) {
|
ScrollView([.horizontal, .vertical]) {
|
||||||
LazyVStack(spacing: 0) {
|
LazyVStack(spacing: 0) {
|
||||||
ForEach(sortedItems) { item in
|
ForEach(sortedItems) { item in
|
||||||
DownloadRow(item: item, visibleColumns: orderedVisibleColumns)
|
DownloadRow(
|
||||||
|
item: item,
|
||||||
|
visibleColumns: orderedVisibleColumns,
|
||||||
|
columnWidth: { width(for: $0) }
|
||||||
|
)
|
||||||
.id(item.id)
|
.id(item.id)
|
||||||
.background(selection == item.id ? Color.accentColor.opacity(0.12) : Color.clear)
|
.background(selection == item.id ? Color.accentColor.opacity(0.12) : Color.clear)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
@@ -245,8 +253,9 @@ private struct DownloadTable: View {
|
|||||||
Divider()
|
Divider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(minWidth: totalWidth, alignment: .topLeading)
|
.frame(minWidth: totalWidth, maxHeight: .infinity, alignment: .topLeading)
|
||||||
}
|
}
|
||||||
|
.defaultScrollAnchor(.topLeading)
|
||||||
.overlay {
|
.overlay {
|
||||||
if items.isEmpty {
|
if items.isEmpty {
|
||||||
ContentUnavailableView(
|
ContentUnavailableView(
|
||||||
@@ -257,33 +266,80 @@ private struct DownloadTable: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.confirmationDialog(
|
||||||
|
"Delete Download",
|
||||||
|
isPresented: Binding(
|
||||||
|
get: { pendingDeleteItem != nil },
|
||||||
|
set: { isPresented in
|
||||||
|
if !isPresented {
|
||||||
|
pendingDeleteItem = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
presenting: pendingDeleteItem
|
||||||
|
) { item in
|
||||||
|
Button("Remove from List") {
|
||||||
|
controller.delete(item, deleteFiles: false)
|
||||||
|
pendingDeleteItem = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Move File and Cache to Trash", role: .destructive) {
|
||||||
|
controller.delete(item, deleteFiles: true)
|
||||||
|
pendingDeleteItem = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Cancel", role: .cancel) {
|
||||||
|
pendingDeleteItem = nil
|
||||||
|
}
|
||||||
|
} message: { item in
|
||||||
|
if item.status == .completed {
|
||||||
|
Text("Remove this download from Firelink, or also move the downloaded file 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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var tableHeader: some View {
|
private var tableHeader: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
ForEach(orderedVisibleColumns) { column in
|
ForEach(orderedVisibleColumns) { column in
|
||||||
Button {
|
HStack(spacing: 0) {
|
||||||
if sortColumn == column {
|
Button {
|
||||||
sortDirection.toggle()
|
|
||||||
} else {
|
|
||||||
sortColumn = column
|
|
||||||
sortDirection = .ascending
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Text(column.rawValue)
|
|
||||||
.font(.caption.weight(.semibold))
|
|
||||||
.lineLimit(1)
|
|
||||||
if sortColumn == column {
|
if sortColumn == column {
|
||||||
Image(systemName: sortDirection == .ascending ? "chevron.up" : "chevron.down")
|
sortDirection.toggle()
|
||||||
.font(.caption2)
|
} else {
|
||||||
|
sortColumn = column
|
||||||
|
sortDirection = .ascending
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
} label: {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(column.rawValue)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.lineLimit(1)
|
||||||
|
if sortColumn == column {
|
||||||
|
Image(systemName: sortDirection == .ascending ? "chevron.up" : "chevron.down")
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.frame(width: max(44, width(for: column) - 8), height: 34, alignment: .leading)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 8)
|
.buttonStyle(.plain)
|
||||||
.frame(width: column.width, height: 34, alignment: .leading)
|
|
||||||
|
Rectangle()
|
||||||
|
.fill(.secondary.opacity(0.18))
|
||||||
|
.frame(width: 1, height: 20)
|
||||||
|
.padding(.horizontal, 3)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.gesture(
|
||||||
|
DragGesture(minimumDistance: 1)
|
||||||
|
.onChanged { value in
|
||||||
|
columnWidths[column] = max(70, column.width + value.translation.width)
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.frame(width: width(for: column), height: 34)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(minWidth: totalWidth, alignment: .leading)
|
.frame(minWidth: totalWidth, alignment: .leading)
|
||||||
@@ -353,7 +409,7 @@ private struct DownloadTable: View {
|
|||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
controller.delete(item)
|
pendingDeleteItem = item
|
||||||
} label: {
|
} label: {
|
||||||
Label("Delete", systemImage: "trash")
|
Label("Delete", systemImage: "trash")
|
||||||
}
|
}
|
||||||
@@ -364,7 +420,11 @@ private struct DownloadTable: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var totalWidth: CGFloat {
|
private var totalWidth: CGFloat {
|
||||||
orderedVisibleColumns.map(\.width).reduce(0, +)
|
orderedVisibleColumns.map { width(for: $0) }.reduce(0, +)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func width(for column: DownloadColumn) -> CGFloat {
|
||||||
|
max(70, columnWidths[column] ?? column.width)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var sortedItems: [DownloadItem] {
|
private var sortedItems: [DownloadItem] {
|
||||||
@@ -427,12 +487,13 @@ private struct DownloadTable: View {
|
|||||||
private struct DownloadRow: View {
|
private struct DownloadRow: View {
|
||||||
let item: DownloadItem
|
let item: DownloadItem
|
||||||
let visibleColumns: [DownloadColumn]
|
let visibleColumns: [DownloadColumn]
|
||||||
|
let columnWidth: (DownloadColumn) -> CGFloat
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top, spacing: 0) {
|
HStack(alignment: .top, spacing: 0) {
|
||||||
ForEach(visibleColumns) { column in
|
ForEach(visibleColumns) { column in
|
||||||
cell(for: column)
|
cell(for: column)
|
||||||
.frame(width: column.width, alignment: .leading)
|
.frame(width: columnWidth(column), alignment: .leading)
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
}
|
}
|
||||||
@@ -453,16 +514,7 @@ private struct DownloadRow: View {
|
|||||||
Text(item.fileName)
|
Text(item.fileName)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Text(item.status.rawValue)
|
|
||||||
.font(.caption)
|
|
||||||
.padding(.horizontal, 7)
|
|
||||||
.padding(.vertical, 3)
|
|
||||||
.background(statusColor.opacity(0.14))
|
|
||||||
.foregroundStyle(statusColor)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 5))
|
|
||||||
}
|
}
|
||||||
ProgressView(value: item.progress)
|
|
||||||
.progressViewStyle(.linear)
|
|
||||||
Text(item.url.absoluteString)
|
Text(item.url.absoluteString)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@@ -475,8 +527,9 @@ private struct DownloadRow: View {
|
|||||||
case .status:
|
case .status:
|
||||||
Text(item.status.rawValue)
|
Text(item.status.rawValue)
|
||||||
case .progress:
|
case .progress:
|
||||||
Text(item.progress, format: .percent.precision(.fractionLength(0)))
|
Text(progressStatusText)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
|
.foregroundStyle(statusColor)
|
||||||
case .lastTry:
|
case .lastTry:
|
||||||
Text(formatted(item.lastTryAt))
|
Text(formatted(item.lastTryAt))
|
||||||
case .dateAdded:
|
case .dateAdded:
|
||||||
@@ -530,6 +583,23 @@ private struct DownloadRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var progressStatusText: String {
|
||||||
|
switch item.status {
|
||||||
|
case .completed:
|
||||||
|
"Completed"
|
||||||
|
case .failed:
|
||||||
|
"Failed"
|
||||||
|
case .canceled:
|
||||||
|
"Canceled"
|
||||||
|
case .paused:
|
||||||
|
"Paused \(item.progress.formatted(.percent.precision(.fractionLength(0))))"
|
||||||
|
case .queued:
|
||||||
|
"Queued"
|
||||||
|
case .downloading:
|
||||||
|
item.progress.formatted(.percent.precision(.fractionLength(0)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func formatted(_ date: Date?) -> String {
|
private func formatted(_ date: Date?) -> String {
|
||||||
guard let date else { return "-" }
|
guard let date else { return "-" }
|
||||||
return date.formatted(date: .abbreviated, time: .shortened)
|
return date.formatted(date: .abbreviated, time: .shortened)
|
||||||
|
|||||||
@@ -149,19 +149,21 @@ final class DownloadController: ObservableObject {
|
|||||||
pumpQueue()
|
pumpQueue()
|
||||||
}
|
}
|
||||||
|
|
||||||
func remove(at offsets: IndexSet) {
|
func remove(at offsets: IndexSet, deleteFiles: Bool = false) {
|
||||||
for index in offsets {
|
for index in offsets {
|
||||||
let item = downloads[index]
|
let item = downloads[index]
|
||||||
activeHandles[item.id]?.cancel()
|
delete(item, deleteFiles: deleteFiles)
|
||||||
activeHandles[item.id] = nil
|
|
||||||
}
|
}
|
||||||
downloads.remove(atOffsets: offsets)
|
|
||||||
updateSleepActivity()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func delete(_ item: DownloadItem) {
|
func delete(_ item: DownloadItem, deleteFiles: Bool = false) {
|
||||||
activeHandles[item.id]?.cancel()
|
activeHandles[item.id]?.cancel()
|
||||||
activeHandles[item.id] = nil
|
activeHandles[item.id] = nil
|
||||||
|
if deleteFiles {
|
||||||
|
trashFiles(for: item)
|
||||||
|
} else if item.status != .completed {
|
||||||
|
removeCacheFiles(for: item)
|
||||||
|
}
|
||||||
downloads.removeAll { $0.id == item.id }
|
downloads.removeAll { $0.id == item.id }
|
||||||
updateSleepActivity()
|
updateSleepActivity()
|
||||||
}
|
}
|
||||||
@@ -288,6 +290,27 @@ final class DownloadController: ObservableObject {
|
|||||||
sleepActivity = nil
|
sleepActivity = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func removeCacheFiles(for item: DownloadItem) {
|
||||||
|
let fileURL = item.destinationDirectory.appendingPathComponent(item.fileName)
|
||||||
|
let candidates = [URL(fileURLWithPath: fileURL.path + ".aria2")]
|
||||||
|
|
||||||
|
for candidate in candidates where FileManager.default.fileExists(atPath: candidate.path) {
|
||||||
|
try? FileManager.default.removeItem(at: candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func trashFiles(for item: DownloadItem) {
|
||||||
|
let fileURL = item.destinationDirectory.appendingPathComponent(item.fileName)
|
||||||
|
let candidates = [
|
||||||
|
fileURL,
|
||||||
|
URL(fileURLWithPath: fileURL.path + ".aria2")
|
||||||
|
]
|
||||||
|
|
||||||
|
for candidate in candidates where FileManager.default.fileExists(atPath: candidate.path) {
|
||||||
|
try? FileManager.default.trashItem(at: candidate, resultingItemURL: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class SleepActivityHandle: @unchecked Sendable {
|
private final class SleepActivityHandle: @unchecked Sendable {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ struct FirelinkApp: App {
|
|||||||
ContentView()
|
ContentView()
|
||||||
.environmentObject(controller)
|
.environmentObject(controller)
|
||||||
.environmentObject(settings)
|
.environmentObject(settings)
|
||||||
.frame(minWidth: 980, minHeight: 640)
|
.frame(minWidth: 1180, idealWidth: 1280, minHeight: 720, idealHeight: 760)
|
||||||
}
|
}
|
||||||
.windowStyle(.titleBar)
|
.windowStyle(.titleBar)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user