From dd9827c20de04527c33e6c8f9c5ecd6ede352c29 Mon Sep 17 00:00:00 2001 From: nimbold <11913706+nimbold@users.noreply.github.com> Date: Mon, 1 Jun 2026 05:26:12 +0330 Subject: [PATCH] feat: improve download table layout --- Sources/Firelink/ContentView.swift | 144 ++++++++++++++++------ Sources/Firelink/DownloadController.swift | 35 +++++- Sources/Firelink/FirelinkApp.swift | 2 +- 3 files changed, 137 insertions(+), 44 deletions(-) diff --git a/Sources/Firelink/ContentView.swift b/Sources/Firelink/ContentView.swift index 2eca254..2f19fb9 100644 --- a/Sources/Firelink/ContentView.swift +++ b/Sources/Firelink/ContentView.swift @@ -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 = [ - .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) diff --git a/Sources/Firelink/DownloadController.swift b/Sources/Firelink/DownloadController.swift index 4896126..7673663 100644 --- a/Sources/Firelink/DownloadController.swift +++ b/Sources/Firelink/DownloadController.swift @@ -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 { diff --git a/Sources/Firelink/FirelinkApp.swift b/Sources/Firelink/FirelinkApp.swift index a6018a5..b352640 100644 --- a/Sources/Firelink/FirelinkApp.swift +++ b/Sources/Firelink/FirelinkApp.swift @@ -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)