Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ora/Core/BrowserEngine/BrowserPage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ final class BrowserPage: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptM
private let webView: WKWebView
private let messageNames: [String]
private var originalURL: URL?
private var lastCommittedURL: URL?
private var isDownloadNavigation = false
private(set) var lastCommittedURL: URL?
private(set) var isDownloadNavigation = false

init(
profile: BrowserEngineProfile,
Expand Down
4 changes: 2 additions & 2 deletions ora/Features/Browser/Views/BrowserView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ struct BrowserView: View {
showFloatingSidebar: $showFloatingSidebar,
isMouseOverSidebar: $isMouseOverSidebar,
sidebarFraction: sidebarManager.currentFraction,
isDownloadsPopoverOpen: downloadManager.isDownloadsPopoverOpen
isDownloadsOpen: downloadManager.isShowingDownloadsHistory
)
}

Expand All @@ -108,7 +108,7 @@ struct BrowserView: View {
.onReceive(NotificationCenter.default.publisher(for: .toggleSidebarPosition)) { _ in
sidebarManager.toggleSidebarPosition()
}
.onChange(of: downloadManager.isDownloadsPopoverOpen) { _, isOpen in
.onChange(of: downloadManager.isShowingDownloadsHistory) { _, isOpen in
if sidebarManager.isSidebarHidden {
if isOpen {
showFloatingSidebar = true
Expand Down
4 changes: 2 additions & 2 deletions ora/Features/Browser/Views/FloatingSidebarOverlay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ struct FloatingSidebarOverlay: View {
@Binding var isMouseOverSidebar: Bool

var sidebarFraction: FractionHolder
let isDownloadsPopoverOpen: Bool
let isDownloadsOpen: Bool

@State private var dragFraction: CGFloat?

Expand Down Expand Up @@ -65,7 +65,7 @@ struct FloatingSidebarOverlay: View {
get: { showFloatingSidebar },
set: { newValue in
isMouseOverSidebar = newValue
if !newValue, isDownloadsPopoverOpen {
if !newValue, isDownloadsOpen {
return
}
showFloatingSidebar = newValue
Expand Down
31 changes: 29 additions & 2 deletions ora/Features/Downloads/Services/DownloadManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import SwiftUI
class DownloadManager: ObservableObject {
@Published var activeDownloads: [Download] = []
@Published var recentDownloads: [Download] = []
@Published var isDownloadsPopoverOpen = false
@Published var isShowingDownloadsHistory = false

let modelContainer: ModelContainer
let modelContext: ModelContext
Expand All @@ -32,7 +32,7 @@ class DownloadManager: ObservableObject {

do {
let downloads = try modelContext.fetch(descriptor)
self.recentDownloads = Array(downloads.prefix(20)) // Show last 20 downloads
self.recentDownloads = Array(downloads.prefix(50))
self.activeDownloads = downloads.filter { $0.status == .downloading }
} catch {
// Failed to load downloads
Expand Down Expand Up @@ -66,6 +66,11 @@ class DownloadManager: ObservableObject {
activeDownloads.append(download)
refreshRecentDownloads()

// Ensure SwiftUI picks up the change when called from WKDownload callbacks
DispatchQueue.main.async {
self.objectWillChange.send()
}

toastManager?.show("Downloading \(suggestedFilename)", type: .info, icon: .system("arrow.down.circle"))

return download
Expand Down Expand Up @@ -201,6 +206,28 @@ class DownloadManager: ObservableObject {
NSWorkspace.shared.selectFile(destinationURL.path, inFileViewerRootedAtPath: "")
}

func openFile(_ download: Download) {
guard let url = download.destinationURL else { return }
NSWorkspace.shared.open(url)
}

/// Moves the downloaded file to Trash and removes the entry from history
func moveToTrash(_ download: Download) {
if let url = download.destinationURL {
try? FileManager.default.trashItem(at: url, resultingItemURL: nil)
}
deleteDownload(download)
}

/// Re-opens the original URL in the browser to re-trigger the download
func retryDownload(_ download: Download) {
guard let url = URL(string: download.originalURLString) else { return }
deleteDownload(download)
if let window = NSApp.keyWindow {
NotificationCenter.default.post(name: .openURL, object: window, userInfo: ["url": url])
}
}

private func refreshRecentDownloads() {
loadRecentDownloads()
}
Expand Down
243 changes: 243 additions & 0 deletions ora/Features/Downloads/Views/DownloadHistoryRow.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
import SwiftUI
import UniformTypeIdentifiers

struct DownloadHistoryRow: View {
let download: Download
@EnvironmentObject var downloadManager: DownloadManager
@Environment(\.theme) private var theme
@State private var isHovered = false

var body: some View {
HStack(spacing: 10) {
fileIconView

VStack(alignment: .leading, spacing: 2) {
Text(download.fileName)
.font(.system(size: 12, weight: .medium))
.foregroundColor(theme.foreground)
.lineLimit(1)
.truncationMode(.middle)

HStack(spacing: 4) {
if let hostname = sourceHostname {
Text(hostname)
.font(.system(size: 10))
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.tail)

if !statusText.isEmpty {
Text("\u{00B7}")
.font(.system(size: 10))
.foregroundColor(.secondary)
.layoutPriority(1)
}
}

Text(statusText)
.font(.system(size: 10))
.foregroundColor(statusColor)
.lineLimit(1)
.truncationMode(.tail)

Spacer(minLength: 0)

if download.status == .completed {
Text(download.formattedFileSize)
.font(.system(size: 10))
.foregroundColor(.secondary)
.lineLimit(1)
.fixedSize()
}
}

if download.status == .downloading {
progressBar
}
}

if isHovered {
moreMenuButton
}
}
.padding(.horizontal, 6)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(isHovered ? theme.mutedBackground.opacity(0.5) : .clear)
)
.contentShape(Rectangle())
.onHover { hovering in
withAnimation(.easeOut(duration: 0.15)) {
isHovered = hovering
}
}
.onTapGesture {
if download.status == .completed {
downloadManager.openFile(download)
}
}
.contextMenu {
downloadMenuItems
}
}

// MARK: - Subviews

private var fileIconView: some View {
Image(nsImage: nativeFileIcon)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 32, height: 32)
}

private var progressBar: some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule()
.fill(theme.mutedBackground)
.frame(height: 3)

Capsule()
.fill(theme.accent)
.frame(width: geo.size.width * download.displayProgress, height: 3)
.animation(.easeOut(duration: 0.2), value: download.displayProgress)
}
}
.frame(height: 3)
.padding(.top, 2)
}

private var moreMenuButton: some View {
Menu {
downloadMenuItems
} label: {
Image(systemName: "ellipsis")
.font(.system(size: 11, weight: .medium))
.foregroundColor(.secondary)
.frame(width: 20, height: 20)
}
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.fixedSize()
}

@ViewBuilder
private var downloadMenuItems: some View {
if download.status == .completed {
Button {
downloadManager.openFile(download)
} label: {
Label("Open", systemImage: "arrow.up.doc")
}

Button {
downloadManager.openDownloadInFinder(download)
} label: {
Label("Show in Finder", systemImage: "folder")
}

Button {
if let path = download.destinationURL?.path {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(path, forType: .string)
}
} label: {
Label("Copy Path", systemImage: "doc.on.doc")
}

Divider()

Button(role: .destructive) {
withAnimation(.easeOut(duration: 0.2)) {
downloadManager.moveToTrash(download)
}
} label: {
Label("Move to Trash", systemImage: "trash")
}
}

if download.status == .downloading {
Button(role: .destructive) {
downloadManager.cancelDownload(download)
} label: {
Label("Cancel Download", systemImage: "xmark.circle")
}
}

if download.status == .failed || download.status == .cancelled {
Button {
downloadManager.retryDownload(download)
} label: {
Label("Retry Download", systemImage: "arrow.clockwise")
}
}

if download.status != .downloading {
Divider()

Button {
withAnimation(.easeOut(duration: 0.2)) {
downloadManager.deleteDownload(download)
}
} label: {
Label("Remove from Ora", systemImage: "minus.circle")
}
}
}

// MARK: - Computed Properties

private var sourceHostname: String? {
guard let url = URL(string: download.originalURLString) else { return nil }
return url.host?.replacingOccurrences(of: "www.", with: "")
}

/// Returns the native macOS file icon for this download, matching what Finder shows.
private var nativeFileIcon: NSImage {
if let url = download.destinationURL,
FileManager.default.fileExists(atPath: url.path)
{
return NSWorkspace.shared.icon(forFile: url.path)
}
let ext = (download.fileName as NSString).pathExtension
if !ext.isEmpty, let utType = UTType(filenameExtension: ext) {
return NSWorkspace.shared.icon(for: utType)
}
return NSWorkspace.shared.icon(for: .data)
}

private var statusColor: Color {
switch download.status {
case .downloading: return theme.accent
case .failed: return .red
case .cancelled: return .orange
default: return .secondary
}
}

private var statusText: String {
switch download.status {
case .downloading:
if download.displayFileSize > 0 {
let pct = Int(download.displayProgress * 100)
return "\(download.formattedDownloadedSize) of \(download.formattedFileSize) \u{00B7} \(pct)%"
}
return download.formattedDownloadedSize
case .completed:
return timeAgo(from: download.completedAt ?? download.createdAt)
case .failed:
return "Failed"
case .cancelled:
return "Cancelled"
default:
return "Pending"
}
}

private func timeAgo(from date: Date) -> String {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
return formatter.localizedString(for: date, relativeTo: Date())
}
}
Loading
Loading