feat: improve download table layout

This commit is contained in:
nimbold
2026-06-01 05:26:12 +03:30
parent 2e78bfe588
commit dd9827c20d
3 changed files with 137 additions and 44 deletions
+107 -37
View File
@@ -156,8 +156,8 @@ private struct SidebarView: View {
enum DownloadColumn: String, CaseIterable, Identifiable {
case fileName = "File name"
case size = "Size"
case status = "Status"
case progress = "Progress"
case status = "Status"
case lastTry = "Last try date"
case dateAdded = "Date added"
case category = "Category"
@@ -173,10 +173,10 @@ enum DownloadColumn: String, CaseIterable, Identifiable {
var width: CGFloat {
switch self {
case .fileName: 320
case .fileName: 340
case .size: 100
case .status: 105
case .progress: 95
case .progress: 115
case .lastTry, .dateAdded: 155
case .category: 105
case .connections, .liveConnections: 95
@@ -206,10 +206,14 @@ private struct DownloadTable: View {
let title: String
@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 sortDirection: SortDirection = .descending
@State private var pendingDeleteItem: DownloadItem?
var body: some View {
VStack(spacing: 0) {
@@ -232,7 +236,11 @@ private struct DownloadTable: View {
ScrollView([.horizontal, .vertical]) {
LazyVStack(spacing: 0) {
ForEach(sortedItems) { item in
DownloadRow(item: item, visibleColumns: orderedVisibleColumns)
DownloadRow(
item: item,
visibleColumns: orderedVisibleColumns,
columnWidth: { width(for: $0) }
)
.id(item.id)
.background(selection == item.id ? Color.accentColor.opacity(0.12) : Color.clear)
.contentShape(Rectangle())
@@ -245,8 +253,9 @@ private struct DownloadTable: View {
Divider()
}
}
.frame(minWidth: totalWidth, alignment: .topLeading)
.frame(minWidth: totalWidth, maxHeight: .infinity, alignment: .topLeading)
}
.defaultScrollAnchor(.topLeading)
.overlay {
if items.isEmpty {
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 {
HStack(spacing: 0) {
ForEach(orderedVisibleColumns) { column in
Button {
if sortColumn == column {
sortDirection.toggle()
} else {
sortColumn = column
sortDirection = .ascending
}
} label: {
HStack(spacing: 4) {
Text(column.rawValue)
.font(.caption.weight(.semibold))
.lineLimit(1)
HStack(spacing: 0) {
Button {
if sortColumn == column {
Image(systemName: sortDirection == .ascending ? "chevron.up" : "chevron.down")
.font(.caption2)
sortDirection.toggle()
} 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)
.frame(width: column.width, height: 34, alignment: .leading)
.buttonStyle(.plain)
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)
@@ -353,7 +409,7 @@ private struct DownloadTable: View {
Divider()
Button(role: .destructive) {
controller.delete(item)
pendingDeleteItem = item
} label: {
Label("Delete", systemImage: "trash")
}
@@ -364,7 +420,11 @@ private struct DownloadTable: View {
}
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] {
@@ -427,12 +487,13 @@ private struct DownloadTable: View {
private struct DownloadRow: View {
let item: DownloadItem
let visibleColumns: [DownloadColumn]
let columnWidth: (DownloadColumn) -> CGFloat
var body: some View {
HStack(alignment: .top, spacing: 0) {
ForEach(visibleColumns) { column in
cell(for: column)
.frame(width: column.width, alignment: .leading)
.frame(width: columnWidth(column), alignment: .leading)
.padding(.horizontal, 8)
.padding(.vertical, 8)
}
@@ -453,16 +514,7 @@ private struct DownloadRow: View {
Text(item.fileName)
.font(.headline)
.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)
.font(.caption)
.foregroundStyle(.secondary)
@@ -475,8 +527,9 @@ private struct DownloadRow: View {
case .status:
Text(item.status.rawValue)
case .progress:
Text(item.progress, format: .percent.precision(.fractionLength(0)))
Text(progressStatusText)
.monospacedDigit()
.foregroundStyle(statusColor)
case .lastTry:
Text(formatted(item.lastTryAt))
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 {
guard let date else { return "-" }
return date.formatted(date: .abbreviated, time: .shortened)
+29 -6
View File
@@ -149,19 +149,21 @@ final class DownloadController: ObservableObject {
pumpQueue()
}
func remove(at offsets: IndexSet) {
func remove(at offsets: IndexSet, deleteFiles: Bool = false) {
for index in offsets {
let item = downloads[index]
activeHandles[item.id]?.cancel()
activeHandles[item.id] = nil
delete(item, deleteFiles: deleteFiles)
}
downloads.remove(atOffsets: offsets)
updateSleepActivity()
}
func delete(_ item: DownloadItem) {
func delete(_ item: DownloadItem, deleteFiles: Bool = false) {
activeHandles[item.id]?.cancel()
activeHandles[item.id] = nil
if deleteFiles {
trashFiles(for: item)
} else if item.status != .completed {
removeCacheFiles(for: item)
}
downloads.removeAll { $0.id == item.id }
updateSleepActivity()
}
@@ -288,6 +290,27 @@ final class DownloadController: ObservableObject {
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 {
+1 -1
View File
@@ -16,7 +16,7 @@ struct FirelinkApp: App {
ContentView()
.environmentObject(controller)
.environmentObject(settings)
.frame(minWidth: 980, minHeight: 640)
.frame(minWidth: 1180, idealWidth: 1280, minHeight: 720, idealHeight: 760)
}
.windowStyle(.titleBar)