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) + } +}