From afb26378d75bb384c1530650b536e1b5a3d61000 Mon Sep 17 00:00:00 2001 From: nimbold <11913706+nimbold@users.noreply.github.com> Date: Mon, 1 Jun 2026 07:57:38 +0330 Subject: [PATCH] feat: persist download history and optimize UI structure This commit breaks down the massive ContentView into smaller components (SidebarView, DownloadTable, StatusBar) and adds state persistence. DownloadTable UI layout and the DownloadController history are now saved efficiently. --- Sources/Firelink/ContentView.swift | 610 ---------------------- Sources/Firelink/DownloadController.swift | 67 ++- Sources/Firelink/DownloadTable.swift | 573 ++++++++++++++++++++ Sources/Firelink/SidebarView.swift | 65 +++ Sources/Firelink/StatusBar.swift | 21 + 5 files changed, 721 insertions(+), 615 deletions(-) create mode 100644 Sources/Firelink/DownloadTable.swift create mode 100644 Sources/Firelink/SidebarView.swift create mode 100644 Sources/Firelink/StatusBar.swift diff --git a/Sources/Firelink/ContentView.swift b/Sources/Firelink/ContentView.swift index 80af898..0a29c82 100644 --- a/Sources/Firelink/ContentView.swift +++ b/Sources/Firelink/ContentView.swift @@ -88,613 +88,3 @@ struct ContentView: View { } } } - -enum DownloadSidebarFilter: Hashable { - case all - case queued - case active - case completed - case failed - case category(DownloadCategory) - - var title: String { - switch self { - case .all: "All Downloads" - case .queued: "Queue" - case .active: "Active" - case .completed: "Completed" - case .failed: "Failed" - case .category(let category): category.rawValue - } - } -} - -private struct SidebarView: View { - @EnvironmentObject private var controller: DownloadController - @Binding var selection: DownloadSidebarFilter - - var body: some View { - List(selection: $selection) { - Section("Library") { - Label("All", systemImage: "tray.full") - .badge(controller.downloads.count) - .tag(DownloadSidebarFilter.all) - Label("Queue", systemImage: "list.bullet") - .badge(controller.queuedCount) - .tag(DownloadSidebarFilter.queued) - Label("Active", systemImage: "bolt.fill") - .badge(controller.activeCount) - .tag(DownloadSidebarFilter.active) - Label("Completed", systemImage: "checkmark.circle") - .badge(controller.completedCount) - .tag(DownloadSidebarFilter.completed) - Label("Failed", systemImage: "exclamationmark.triangle") - .badge(controller.failedCount) - .tag(DownloadSidebarFilter.failed) - } - - Section("Folders") { - ForEach(DownloadCategory.allCases, id: \.self) { category in - Label(category.rawValue, systemImage: category.symbolName) - .badge(controller.downloads.filter { $0.category == category }.count) - .tag(DownloadSidebarFilter.category(category)) - } - } - - Section("Engine") { - HStack { - Image(systemName: controller.hasAria2 ? "checkmark.seal.fill" : "exclamationmark.triangle.fill") - .foregroundStyle(controller.hasAria2 ? .green : .orange) - Text(controller.hasAria2 ? "aria2c ready" : "aria2c missing") - } - } - } - .listStyle(.sidebar) - } -} - -enum DownloadColumn: String, CaseIterable, Identifiable { - case fileName = "File name" - case size = "Size" - case progress = "Progress" - case status = "Status" - case lastTry = "Last try date" - case dateAdded = "Date added" - case category = "Category" - case connections = "Connections" - case liveConnections = "Live conn." - case speed = "Speed" - case eta = "ETA" - case destination = "Save location" - case url = "URL" - case message = "Message" - - var id: String { rawValue } - - var width: CGFloat { - switch self { - case .fileName: 340 - case .size: 100 - case .status: 105 - case .progress: 115 - case .lastTry, .dateAdded: 155 - case .category: 105 - case .connections, .liveConnections: 95 - case .speed, .eta: 90 - case .destination: 240 - case .url: 280 - case .message: 220 - } - } -} - -enum SortDirection { - case ascending - case descending - - mutating func toggle() { - self = self == .ascending ? .descending : .ascending - } -} - -private struct DownloadTable: View { - @EnvironmentObject private var controller: DownloadController - @EnvironmentObject private var settings: AppSettings - @Environment(\.openWindow) private var openWindow - let items: [DownloadItem] - @Binding var selection: DownloadItem.ID? - let title: String - - @State private var visibleColumns: Set = [ - .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? - @State private var resizeBaseWidths: [DownloadColumn: CGFloat] = [:] - - var body: some View { - VStack(spacing: 0) { - HStack { - Text(title) - .font(.headline) - Text("\(items.count)") - .font(.caption) - .foregroundStyle(.secondary) - .padding(.horizontal, 7) - .padding(.vertical, 3) - .background(.quaternary.opacity(0.5)) - .clipShape(RoundedRectangle(cornerRadius: 5)) - Spacer() - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - GeometryReader { proxy in - let tableWidth = max(totalWidth, proxy.size.width) - let trailingWidth = max(0, tableWidth - totalWidth) - - ScrollView(.horizontal) { - VStack(spacing: 0) { - tableHeader(trailingWidth: trailingWidth) - Divider() - ScrollView(.vertical) { - LazyVStack(spacing: 0) { - ForEach(sortedItems) { item in - tableRow(for: item, tableWidth: tableWidth, trailingWidth: trailingWidth) - Divider() - } - } - .frame(width: tableWidth, alignment: .topLeading) - .frame(maxHeight: .infinity, alignment: .topLeading) - } - .defaultScrollAnchor(.topLeading) - } - .frame(width: tableWidth, height: proxy.size.height, alignment: .topLeading) - } - .overlay { - if items.isEmpty { - ContentUnavailableView( - "No Downloads", - systemImage: "arrow.down.circle", - description: Text("Use Add to paste one or more links.") - ) - } - } - } - } - .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 func tableRow(for item: DownloadItem, tableWidth: CGFloat, trailingWidth: CGFloat) -> some View { - DownloadRow( - item: item, - visibleColumns: orderedVisibleColumns, - columnWidth: { width(for: $0) }, - trailingWidth: trailingWidth - ) - .id(item.id) - .frame(width: tableWidth, alignment: .leading) - .background(selection == item.id ? Color.accentColor.opacity(0.12) : Color.clear) - .contentShape(Rectangle()) - .onTapGesture { - selection = item.id - } - .contextMenu { - rowContextMenu(for: item) - } - } - - private func tableHeader(trailingWidth: CGFloat) -> some View { - HStack(spacing: 0) { - ForEach(orderedVisibleColumns) { column in - ZStack(alignment: .trailing) { - Button { - if sortColumn == column { - sortDirection.toggle() - } else { - sortColumn = column - sortDirection = .ascending - } - } label: { - HStack(spacing: 4) { - Spacer(minLength: 0) - Text(column.rawValue) - .font(.caption.weight(.semibold)) - .lineLimit(1) - .truncationMode(.tail) - .multilineTextAlignment(.center) - .layoutPriority(1) - if sortColumn == column { - Image(systemName: sortDirection == .ascending ? "chevron.up" : "chevron.down") - .font(.caption2) - } - Spacer(minLength: 0) - } - .padding(.horizontal, 8) - .frame(width: width(for: column), height: 34, alignment: .center) - .clipped() - } - .buttonStyle(.plain) - - Rectangle() - .fill(.secondary.opacity(0.18)) - .frame(width: 1, height: 20) - .frame(width: 8, height: 34) - .contentShape(Rectangle()) - .onHover { isHovering in - if isHovering { - NSCursor.resizeLeftRight.push() - } else { - NSCursor.pop() - } - } - .gesture( - DragGesture(minimumDistance: 1) - .onChanged { value in - let baseWidth = resizeBaseWidths[column] ?? width(for: column) - resizeBaseWidths[column] = baseWidth - columnWidths[column] = max(70, baseWidth + value.translation.width) - } - .onEnded { _ in - resizeBaseWidths[column] = nil - } - ) - } - .frame(width: width(for: column), height: 34) - .clipped() - } - - if trailingWidth > 0 { - Color.clear - .frame(width: trailingWidth, height: 34) - } - } - .background(.bar) - .contextMenu { - Section("Columns") { - ForEach(DownloadColumn.allCases) { column in - Toggle(column.rawValue, isOn: Binding( - get: { visibleColumns.contains(column) }, - set: { isVisible in - if isVisible { - visibleColumns.insert(column) - } else if visibleColumns.count > 1 { - visibleColumns.remove(column) - } - } - )) - } - } - Section("Sort By") { - ForEach(DownloadColumn.allCases) { column in - Button(column.rawValue) { - sortColumn = column - } - } - } - } - } - - @ViewBuilder - private func rowContextMenu(for item: DownloadItem) -> some View { - Button { - openWindow(value: item.id) - } label: { - Label("Properties", systemImage: "info.circle") - } - - Button { - showInFinder(item) - } label: { - Label("Show in Finder", systemImage: "finder") - } - - Divider() - - Button { - controller.resume(item) - } label: { - Label("Resume", systemImage: "arrow.clockwise") - } - .disabled(item.status == .downloading) - - Button { - controller.pause(item) - } label: { - Label("Stop", systemImage: "stop.fill") - } - .disabled(item.status != .downloading) - - Button { - controller.queue(item) - } label: { - Label("Add to Queue", systemImage: "list.bullet") - } - .disabled(item.status == .downloading || item.status == .queued) - - Divider() - - Button(role: .destructive) { - pendingDeleteItem = item - } label: { - Label("Delete", systemImage: "trash") - } - } - - private var orderedVisibleColumns: [DownloadColumn] { - DownloadColumn.allCases.filter { visibleColumns.contains($0) } - } - - private var totalWidth: CGFloat { - orderedVisibleColumns.map { width(for: $0) }.reduce(0, +) - } - - private func width(for column: DownloadColumn) -> CGFloat { - max(70, columnWidths[column] ?? column.width) - } - - private var sortedItems: [DownloadItem] { - items.sorted { lhs, rhs in - let result = compare(lhs, rhs, by: sortColumn) - if result == .orderedSame { - return lhs.id.uuidString < rhs.id.uuidString - } - return sortDirection == .ascending ? result == .orderedAscending : result == .orderedDescending - } - } - - private func compare(_ lhs: DownloadItem, _ rhs: DownloadItem, by column: DownloadColumn) -> ComparisonResult { - switch column { - case .fileName: lhs.fileName.localizedCaseInsensitiveCompare(rhs.fileName) - case .size: compare(lhs.sizeBytes ?? -1, rhs.sizeBytes ?? -1) - case .status: compare(lhs.status.rawValue, rhs.status.rawValue) - case .progress: compare(lhs.progress, rhs.progress) - case .lastTry: compare(lhs.lastTryAt ?? .distantPast, rhs.lastTryAt ?? .distantPast) - case .dateAdded: compare(lhs.createdAt, rhs.createdAt) - case .category: compare(lhs.category.rawValue, rhs.category.rawValue) - case .connections: compare(lhs.connectionsPerServer, rhs.connectionsPerServer) - case .liveConnections: compare(lhs.connectionCount, rhs.connectionCount) - case .speed: compare(lhs.speedText, rhs.speedText) - case .eta: compare(lhs.etaText, rhs.etaText) - case .destination: compare(lhs.destinationPath, rhs.destinationPath) - case .url: compare(lhs.url.absoluteString, rhs.url.absoluteString) - case .message: compare(lhs.message, rhs.message) - } - } - - private func compare(_ lhs: T, _ rhs: T) -> ComparisonResult { - if lhs < rhs { return .orderedAscending } - if lhs > rhs { return .orderedDescending } - return .orderedSame - } - - private func showInFinder(_ item: DownloadItem) { - let fileURL = item.destinationDirectory.appendingPathComponent(item.fileName) - if FileManager.default.fileExists(atPath: fileURL.path) { - NSWorkspace.shared.activateFileViewerSelecting([fileURL]) - } else { - NSWorkspace.shared.open(existingFolder(for: item.destinationDirectory)) - } - } - - private func existingFolder(for url: URL) -> URL { - var candidate = url - while !FileManager.default.fileExists(atPath: candidate.path) { - let parent = candidate.deletingLastPathComponent() - if parent.path == candidate.path { - return URL(fileURLWithPath: NSHomeDirectory()) - } - candidate = parent - } - return candidate - } -} - -private struct DownloadRow: View { - let item: DownloadItem - let visibleColumns: [DownloadColumn] - let columnWidth: (DownloadColumn) -> CGFloat - let trailingWidth: CGFloat - - var body: some View { - HStack(alignment: .top, spacing: 0) { - ForEach(visibleColumns) { column in - cell(for: column) - .padding(.horizontal, 8) - .padding(.vertical, 8) - .frame(width: columnWidth(column), alignment: alignment(for: column)) - .clipped() - } - - if trailingWidth > 0 { - Color.clear - .frame(width: trailingWidth) - } - } - } - - @ViewBuilder - private func cell(for column: DownloadColumn) -> some View { - switch column { - case .fileName: - HStack(alignment: .top, spacing: 8) { - Image(systemName: item.category.symbolName) - .font(.title3) - .foregroundStyle(categoryColor) - .frame(width: 22) - VStack(alignment: .leading, spacing: 6) { - Text(item.fileName) - .font(.headline) - .lineLimit(1) - .truncationMode(.tail) - Text(item.url.absoluteString) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - case .size: - Text(ByteFormatter.string(item.sizeBytes)) - .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 .lastTry: - Text(formatted(item.lastTryAt)) - .lineLimit(1) - .truncationMode(.tail) - case .dateAdded: - Text(formatted(item.createdAt)) - .lineLimit(1) - .truncationMode(.tail) - case .category: - Text(item.category.rawValue) - .lineLimit(1) - .truncationMode(.tail) - case .connections: - Text("\(item.connectionsPerServer)") - .monospacedDigit() - .lineLimit(1) - case .liveConnections: - Text("\(item.connectionCount)") - .monospacedDigit() - .lineLimit(1) - case .speed: - Text(item.speedText) - .lineLimit(1) - .truncationMode(.tail) - case .eta: - Text(item.etaText) - .lineLimit(1) - .truncationMode(.tail) - case .destination: - Text(item.destinationPath) - .font(.system(.caption, design: .monospaced)) - .lineLimit(1) - .truncationMode(.tail) - case .url: - Text(item.url.absoluteString) - .font(.system(.caption, design: .monospaced)) - .lineLimit(1) - .truncationMode(.tail) - case .message: - Text(item.message) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - } - } - - private func alignment(for column: DownloadColumn) -> Alignment { - column == .fileName ? .leading : .center - } - - private var categoryColor: Color { - switch item.category { - case .musics: .pink - case .movies: .indigo - case .compressed: .orange - case .pictures: .teal - case .documents: .blue - case .other: .gray - } - } - - private var statusColor: Color { - switch item.status { - case .queued: .secondary - case .downloading: .blue - case .paused: .orange - case .completed: .green - case .failed: .red - case .canceled: .gray - } - } - - 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) - } -} - -private struct StatusBar: View { - @EnvironmentObject private var controller: DownloadController - - var body: some View { - HStack { - Text(controller.engineMessage.isEmpty ? "Ready" : controller.engineMessage) - .foregroundStyle(controller.hasAria2 ? Color.secondary : Color.orange) - .lineLimit(1) - Spacer() - Text("\(controller.activeCount) active") - Text("\(controller.queuedCount) queued") - Text("\(controller.completedCount) done") - } - .font(.caption) - .padding(.horizontal, 14) - .padding(.vertical, 8) - .background(.bar) - } -} diff --git a/Sources/Firelink/DownloadController.swift b/Sources/Firelink/DownloadController.swift index 7673663..ea48ea9 100644 --- a/Sources/Firelink/DownloadController.swift +++ b/Sources/Firelink/DownloadController.swift @@ -1,3 +1,4 @@ +import AppKit import Combine import Foundation @@ -10,15 +11,38 @@ final class DownloadController: ObservableObject { private let engine = Aria2DownloadEngine() private var activeHandles: [UUID: Aria2DownloadEngine.Handle] = [:] private var sleepActivity: SleepActivityHandle? - private var settingsCancellable: AnyCancellable? + private var cancellables = Set() + private lazy var storageURL: URL = { + let supportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? URL(fileURLWithPath: NSHomeDirectory()) + return supportDir.appendingPathComponent("Firelink").appendingPathComponent("downloads.json") + }() init(settings: AppSettings) { self.settings = settings - settingsCancellable = settings.$preventsSleepWhileDownloading.sink { [weak self] _ in - Task { @MainActor in - self?.updateSleepActivity() + + loadDownloads() + + settings.$preventsSleepWhileDownloading + .sink { [weak self] _ in + Task { @MainActor in + self?.updateSleepActivity() + } } - } + .store(in: &cancellables) + + $downloads + .dropFirst() + .debounce(for: .seconds(2.0), scheduler: RunLoop.main) + .sink { [weak self] _ in + self?.saveDownloads() + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification) + .sink { [weak self] _ in + self?.saveDownloads() + } + .store(in: &cancellables) } deinit { @@ -311,6 +335,39 @@ final class DownloadController: ObservableObject { try? FileManager.default.trashItem(at: candidate, resultingItemURL: nil) } } + + private func saveDownloads() { + do { + let directory = storageURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) + let data = try JSONEncoder().encode(downloads) + try data.write(to: storageURL, options: .atomic) + } catch { + print("Failed to save downloads: \(error)") + } + } + + private func loadDownloads() { + do { + guard FileManager.default.fileExists(atPath: storageURL.path) else { return } + let data = try Data(contentsOf: storageURL) + let loaded = try JSONDecoder().decode([DownloadItem].self, from: data) + + self.downloads = loaded.map { item in + var adjusted = item + if adjusted.status == .downloading { + adjusted.status = .paused + adjusted.message = "Paused on startup" + adjusted.speedText = "-" + adjusted.etaText = "-" + adjusted.connectionCount = 0 + } + return adjusted + } + } catch { + print("Failed to load downloads: \(error)") + } + } } private final class SleepActivityHandle: @unchecked Sendable { diff --git a/Sources/Firelink/DownloadTable.swift b/Sources/Firelink/DownloadTable.swift new file mode 100644 index 0000000..c8da7eb --- /dev/null +++ b/Sources/Firelink/DownloadTable.swift @@ -0,0 +1,573 @@ +import AppKit +import SwiftUI + +enum DownloadColumn: String, CaseIterable, Identifiable, Codable { + case fileName = "File name" + case size = "Size" + case progress = "Progress" + case status = "Status" + case lastTry = "Last try date" + case dateAdded = "Date added" + case category = "Category" + case connections = "Connections" + case liveConnections = "Live conn." + case speed = "Speed" + case eta = "ETA" + case destination = "Save location" + case url = "URL" + case message = "Message" + + var id: String { rawValue } + + var width: CGFloat { + switch self { + case .fileName: return 340 + case .size: return 100 + case .status: return 105 + case .progress: return 115 + case .lastTry, .dateAdded: return 155 + case .category: return 105 + case .connections, .liveConnections: return 95 + case .speed, .eta: return 90 + case .destination: return 240 + case .url: return 280 + case .message: return 220 + } + } +} + +enum SortDirection: String, Codable { + case ascending + case descending + + mutating func toggle() { + self = self == .ascending ? .descending : .ascending + } +} + +final class TableSettings: ObservableObject { + @Published var visibleColumns: Set { + didSet { save() } + } + @Published var columnWidths: [DownloadColumn: CGFloat] { + didSet { save() } + } + @Published var sortColumn: DownloadColumn { + didSet { save() } + } + @Published var sortDirection: SortDirection { + didSet { save() } + } + + private let defaults = UserDefaults.standard + private let storageKey = "Firelink.TableSettings.v1" + + init() { + if let data = defaults.data(forKey: storageKey), + let stored = try? JSONDecoder().decode(StoredTableSettings.self, from: data) { + visibleColumns = stored.visibleColumns + columnWidths = stored.columnWidths + sortColumn = stored.sortColumn + sortDirection = stored.sortDirection + } else { + visibleColumns = [.fileName, .size, .progress, .eta, .lastTry, .dateAdded] + columnWidths = Dictionary(uniqueKeysWithValues: DownloadColumn.allCases.map { ($0, $0.width) }) + sortColumn = .dateAdded + sortDirection = .descending + } + } + + private func save() { + let stored = StoredTableSettings( + visibleColumns: visibleColumns, + columnWidths: columnWidths, + sortColumn: sortColumn, + sortDirection: sortDirection + ) + if let data = try? JSONEncoder().encode(stored) { + defaults.set(data, forKey: storageKey) + } + } +} + +private struct StoredTableSettings: Codable { + var visibleColumns: Set + var columnWidths: [DownloadColumn: CGFloat] + var sortColumn: DownloadColumn + var sortDirection: SortDirection +} + +struct DownloadTable: View { + @EnvironmentObject private var controller: DownloadController + @EnvironmentObject private var settings: AppSettings + @Environment(\.openWindow) private var openWindow + let items: [DownloadItem] + @Binding var selection: DownloadItem.ID? + let title: String + + @StateObject private var tableSettings = TableSettings() + @State private var pendingDeleteItem: DownloadItem? + @State private var resizeBaseWidths: [DownloadColumn: CGFloat] = [:] + + var body: some View { + VStack(spacing: 0) { + HStack { + Text(title) + .font(.headline) + Text("\(items.count)") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(.quaternary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 5)) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + GeometryReader { proxy in + let tableWidth = max(totalWidth, proxy.size.width) + let trailingWidth = max(0, tableWidth - totalWidth) + + ScrollView(.horizontal) { + VStack(spacing: 0) { + tableHeader(trailingWidth: trailingWidth) + Divider() + ScrollView(.vertical) { + LazyVStack(spacing: 0) { + ForEach(sortedItems) { item in + tableRow(for: item, tableWidth: tableWidth, trailingWidth: trailingWidth) + Divider() + } + } + .frame(width: tableWidth, alignment: .topLeading) + .frame(maxHeight: .infinity, alignment: .topLeading) + } + .defaultScrollAnchor(.topLeading) + } + .frame(width: tableWidth, height: proxy.size.height, alignment: .topLeading) + } + .overlay { + if items.isEmpty { + ContentUnavailableView( + "No Downloads", + systemImage: "arrow.down.circle", + description: Text("Use Add to paste one or more links.") + ) + } + } + } + } + .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 func tableRow(for item: DownloadItem, tableWidth: CGFloat, trailingWidth: CGFloat) -> some View { + DownloadRow( + item: item, + visibleColumns: orderedVisibleColumns, + columnWidth: { width(for: $0) }, + trailingWidth: trailingWidth + ) + .id(item.id) + .frame(width: tableWidth, alignment: .leading) + .background(selection == item.id ? Color.accentColor.opacity(0.12) : Color.clear) + .contentShape(Rectangle()) + .onTapGesture { + selection = item.id + } + .contextMenu { + rowContextMenu(for: item) + } + } + + private func tableHeader(trailingWidth: CGFloat) -> some View { + HStack(spacing: 0) { + ForEach(orderedVisibleColumns) { column in + ZStack(alignment: .trailing) { + Button { + if tableSettings.sortColumn == column { + tableSettings.sortDirection.toggle() + } else { + tableSettings.sortColumn = column + tableSettings.sortDirection = .ascending + } + } label: { + HStack(spacing: 4) { + Spacer(minLength: 0) + Text(column.rawValue) + .font(.caption.weight(.semibold)) + .lineLimit(1) + .truncationMode(.tail) + .multilineTextAlignment(.center) + .layoutPriority(1) + if tableSettings.sortColumn == column { + Image(systemName: tableSettings.sortDirection == .ascending ? "chevron.up" : "chevron.down") + .font(.caption2) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 8) + .frame(width: width(for: column), height: 34, alignment: .center) + .clipped() + } + .buttonStyle(.plain) + + Rectangle() + .fill(.secondary.opacity(0.18)) + .frame(width: 1, height: 20) + .frame(width: 8, height: 34) + .contentShape(Rectangle()) + .onHover { isHovering in + if isHovering { + NSCursor.resizeLeftRight.push() + } else { + NSCursor.pop() + } + } + .gesture( + DragGesture(minimumDistance: 1) + .onChanged { value in + let baseWidth = resizeBaseWidths[column] ?? width(for: column) + resizeBaseWidths[column] = baseWidth + tableSettings.columnWidths[column] = max(70, baseWidth + value.translation.width) + } + .onEnded { _ in + resizeBaseWidths[column] = nil + } + ) + } + .frame(width: width(for: column), height: 34) + .clipped() + } + + if trailingWidth > 0 { + Color.clear + .frame(width: trailingWidth, height: 34) + } + } + .background(.bar) + .contextMenu { + Section("Columns") { + ForEach(DownloadColumn.allCases) { column in + Toggle(column.rawValue, isOn: Binding( + get: { tableSettings.visibleColumns.contains(column) }, + set: { isVisible in + if isVisible { + tableSettings.visibleColumns.insert(column) + } else if tableSettings.visibleColumns.count > 1 { + tableSettings.visibleColumns.remove(column) + } + } + )) + } + } + Section("Sort By") { + ForEach(DownloadColumn.allCases) { column in + Button(column.rawValue) { + tableSettings.sortColumn = column + } + } + } + } + } + + @ViewBuilder + private func rowContextMenu(for item: DownloadItem) -> some View { + Button { + openWindow(value: item.id) + } label: { + Label("Properties", systemImage: "info.circle") + } + + Button { + showInFinder(item) + } label: { + Label("Show in Finder", systemImage: "finder") + } + + Divider() + + Button { + controller.resume(item) + } label: { + Label("Resume", systemImage: "arrow.clockwise") + } + .disabled(item.status == .downloading) + + Button { + controller.pause(item) + } label: { + Label("Stop", systemImage: "stop.fill") + } + .disabled(item.status != .downloading) + + Button { + controller.queue(item) + } label: { + Label("Add to Queue", systemImage: "list.bullet") + } + .disabled(item.status == .downloading || item.status == .queued) + + Divider() + + Button(role: .destructive) { + pendingDeleteItem = item + } label: { + Label("Delete", systemImage: "trash") + } + } + + private var orderedVisibleColumns: [DownloadColumn] { + DownloadColumn.allCases.filter { tableSettings.visibleColumns.contains($0) } + } + + private var totalWidth: CGFloat { + orderedVisibleColumns.map { width(for: $0) }.reduce(0, +) + } + + private func width(for column: DownloadColumn) -> CGFloat { + max(70, tableSettings.columnWidths[column] ?? column.width) + } + + private var sortedItems: [DownloadItem] { + items.sorted { lhs, rhs in + let result = compare(lhs, rhs, by: tableSettings.sortColumn) + if result == .orderedSame { + return lhs.id.uuidString < rhs.id.uuidString + } + return tableSettings.sortDirection == .ascending ? result == .orderedAscending : result == .orderedDescending + } + } + + private func compare(_ lhs: DownloadItem, _ rhs: DownloadItem, by column: DownloadColumn) -> ComparisonResult { + switch column { + case .fileName: return lhs.fileName.localizedCaseInsensitiveCompare(rhs.fileName) + case .size: return compare(lhs.sizeBytes ?? -1, rhs.sizeBytes ?? -1) + case .status: return compare(lhs.status.rawValue, rhs.status.rawValue) + case .progress: return compare(lhs.progress, rhs.progress) + case .lastTry: return compare(lhs.lastTryAt ?? .distantPast, rhs.lastTryAt ?? .distantPast) + case .dateAdded: return compare(lhs.createdAt, rhs.createdAt) + case .category: return compare(lhs.category.rawValue, rhs.category.rawValue) + case .connections: return compare(lhs.connectionsPerServer, rhs.connectionsPerServer) + case .liveConnections: return compare(lhs.connectionCount, rhs.connectionCount) + case .speed: return compare(lhs.speedText, rhs.speedText) + case .eta: return compare(lhs.etaText, rhs.etaText) + case .destination: return compare(lhs.destinationPath, rhs.destinationPath) + case .url: return compare(lhs.url.absoluteString, rhs.url.absoluteString) + case .message: return compare(lhs.message, rhs.message) + } + } + + private func compare(_ lhs: T, _ rhs: T) -> ComparisonResult { + if lhs < rhs { return .orderedAscending } + if lhs > rhs { return .orderedDescending } + return .orderedSame + } + + private func showInFinder(_ item: DownloadItem) { + let fileURL = item.destinationDirectory.appendingPathComponent(item.fileName) + if FileManager.default.fileExists(atPath: fileURL.path) { + NSWorkspace.shared.activateFileViewerSelecting([fileURL]) + } else { + NSWorkspace.shared.open(existingFolder(for: item.destinationDirectory)) + } + } + + private func existingFolder(for url: URL) -> URL { + var candidate = url + while !FileManager.default.fileExists(atPath: candidate.path) { + let parent = candidate.deletingLastPathComponent() + if parent.path == candidate.path { + return URL(fileURLWithPath: NSHomeDirectory()) + } + candidate = parent + } + return candidate + } +} + +private struct DownloadRow: View { + let item: DownloadItem + let visibleColumns: [DownloadColumn] + let columnWidth: (DownloadColumn) -> CGFloat + let trailingWidth: CGFloat + + var body: some View { + HStack(alignment: .top, spacing: 0) { + ForEach(visibleColumns) { column in + cell(for: column) + .padding(.horizontal, 8) + .padding(.vertical, 8) + .frame(width: columnWidth(column), alignment: alignment(for: column)) + .clipped() + } + + if trailingWidth > 0 { + Color.clear + .frame(width: trailingWidth) + } + } + } + + @ViewBuilder + private func cell(for column: DownloadColumn) -> some View { + switch column { + case .fileName: + HStack(alignment: .top, spacing: 8) { + Image(systemName: item.category.symbolName) + .font(.title3) + .foregroundStyle(categoryColor) + .frame(width: 22) + VStack(alignment: .leading, spacing: 6) { + Text(item.fileName) + .font(.headline) + .lineLimit(1) + .truncationMode(.tail) + Text(item.url.absoluteString) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + case .size: + Text(ByteFormatter.string(item.sizeBytes)) + .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 .lastTry: + Text(formatted(item.lastTryAt)) + .lineLimit(1) + .truncationMode(.tail) + case .dateAdded: + Text(formatted(item.createdAt)) + .lineLimit(1) + .truncationMode(.tail) + case .category: + Text(item.category.rawValue) + .lineLimit(1) + .truncationMode(.tail) + case .connections: + Text("\(item.connectionsPerServer)") + .monospacedDigit() + .lineLimit(1) + case .liveConnections: + Text("\(item.connectionCount)") + .monospacedDigit() + .lineLimit(1) + case .speed: + Text(item.speedText) + .lineLimit(1) + .truncationMode(.tail) + case .eta: + Text(item.etaText) + .lineLimit(1) + .truncationMode(.tail) + case .destination: + Text(item.destinationPath) + .font(.system(.caption, design: .monospaced)) + .lineLimit(1) + .truncationMode(.tail) + case .url: + Text(item.url.absoluteString) + .font(.system(.caption, design: .monospaced)) + .lineLimit(1) + .truncationMode(.tail) + case .message: + Text(item.message) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + } + + private func alignment(for column: DownloadColumn) -> Alignment { + column == .fileName ? .leading : .center + } + + private var categoryColor: Color { + switch item.category { + case .musics: return .pink + case .movies: return .indigo + case .compressed: return .orange + case .pictures: return .teal + case .documents: return .blue + case .other: return .gray + } + } + + private var statusColor: Color { + switch item.status { + case .queued: return .secondary + case .downloading: return .blue + case .paused: return .orange + case .completed: return .green + case .failed: return .red + case .canceled: return .gray + } + } + + 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))) + } + } + + private func formatted(_ date: Date?) -> String { + guard let date else { return "-" } + return date.formatted(date: .abbreviated, time: .shortened) + } +} diff --git a/Sources/Firelink/SidebarView.swift b/Sources/Firelink/SidebarView.swift new file mode 100644 index 0000000..6a5a886 --- /dev/null +++ b/Sources/Firelink/SidebarView.swift @@ -0,0 +1,65 @@ +import SwiftUI + +enum DownloadSidebarFilter: Hashable { + case all + case queued + case active + case completed + case failed + case category(DownloadCategory) + + var title: String { + switch self { + case .all: "All Downloads" + case .queued: "Queue" + case .active: "Active" + case .completed: "Completed" + case .failed: "Failed" + case .category(let category): category.rawValue + } + } +} + +struct SidebarView: View { + @EnvironmentObject private var controller: DownloadController + @Binding var selection: DownloadSidebarFilter + + var body: some View { + List(selection: $selection) { + Section("Library") { + Label("All", systemImage: "tray.full") + .badge(controller.downloads.count) + .tag(DownloadSidebarFilter.all) + Label("Queue", systemImage: "list.bullet") + .badge(controller.queuedCount) + .tag(DownloadSidebarFilter.queued) + Label("Active", systemImage: "bolt.fill") + .badge(controller.activeCount) + .tag(DownloadSidebarFilter.active) + Label("Completed", systemImage: "checkmark.circle") + .badge(controller.completedCount) + .tag(DownloadSidebarFilter.completed) + Label("Failed", systemImage: "exclamationmark.triangle") + .badge(controller.failedCount) + .tag(DownloadSidebarFilter.failed) + } + + Section("Folders") { + ForEach(DownloadCategory.allCases, id: \.self) { category in + Label(category.rawValue, systemImage: category.symbolName) + .badge(controller.downloads.filter { $0.category == category }.count) + .tag(DownloadSidebarFilter.category(category)) + } + } + + Section("Engine") { + HStack { + Image(systemName: controller.hasAria2 ? "checkmark.seal.fill" : "exclamationmark.triangle.fill") + .foregroundStyle(controller.hasAria2 ? .green : .orange) + Text(controller.hasAria2 ? "aria2c ready" : "aria2c missing") + } + } + } + .listStyle(.sidebar) + } +} diff --git a/Sources/Firelink/StatusBar.swift b/Sources/Firelink/StatusBar.swift new file mode 100644 index 0000000..fca2207 --- /dev/null +++ b/Sources/Firelink/StatusBar.swift @@ -0,0 +1,21 @@ +import SwiftUI + +struct StatusBar: View { + @EnvironmentObject private var controller: DownloadController + + var body: some View { + HStack { + Text(controller.engineMessage.isEmpty ? "Ready" : controller.engineMessage) + .foregroundStyle(controller.hasAria2 ? Color.secondary : Color.orange) + .lineLimit(1) + Spacer() + Text("\(controller.activeCount) active") + Text("\(controller.queuedCount) queued") + Text("\(controller.completedCount) done") + } + .font(.caption) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(.bar) + } +}