diff --git a/ora/Common/Constants/AppEvents.swift b/ora/Common/Constants/AppEvents.swift index 5195393b..afbf2d4d 100644 --- a/ora/Common/Constants/AppEvents.swift +++ b/ora/Common/Constants/AppEvents.swift @@ -17,6 +17,7 @@ extension Notification.Name { static let previousTab = Notification.Name("PreviousTab") static let toggleToolbar = Notification.Name("ToggleToolbar") static let selectTabAtIndex = Notification.Name("SelectTabAtIndex") // userInfo: ["index": Int] + static let showHistory = Notification.Name("ShowHistory") // Per-window settings/events static let setAppearance = Notification.Name("SetAppearance") // userInfo: ["appearance": String] diff --git a/ora/Common/Utils/WindowFactory.swift b/ora/Common/Utils/WindowFactory.swift index b66f9941..15ee5251 100644 --- a/ora/Common/Utils/WindowFactory.swift +++ b/ora/Common/Utils/WindowFactory.swift @@ -21,6 +21,3 @@ enum WindowFactory { return window } } - - - diff --git a/ora/Models/History.swift b/ora/Models/History.swift index 042ed436..32cd8a25 100644 --- a/ora/Models/History.swift +++ b/ora/Models/History.swift @@ -8,11 +8,9 @@ final class History { var url: URL var urlString: String var title: String - var faviconURL: URL + var faviconURL: URL? var faviconLocalFile: URL? - var createdAt: Date - var visitCount: Int - var lastAccessedAt: Date + var visitedAt: Date? // When this specific visit occurred @Relationship(inverse: \TabContainer.history) var container: TabContainer? @@ -20,23 +18,18 @@ final class History { id: UUID = UUID(), url: URL, title: String, - faviconURL: URL, + faviconURL: URL? = nil, faviconLocalFile: URL? = nil, - createdAt: Date, - lastAccessedAt: Date, - visitCount: Int, + visitedAt: Date? = Date(), container: TabContainer? = nil ) { - let now = Date() self.id = id self.url = url self.urlString = url.absoluteString self.title = title self.faviconURL = faviconURL - self.createdAt = now - self.lastAccessedAt = now - self.visitCount = visitCount self.faviconLocalFile = faviconLocalFile + self.visitedAt = visitedAt self.container = container } } diff --git a/ora/Models/Tab.swift b/ora/Models/Tab.swift index bed015f5..ec0b50fa 100644 --- a/ora/Models/Tab.swift +++ b/ora/Models/Tab.swift @@ -168,17 +168,40 @@ class Tab: ObservableObject, Identifiable { } } + @Transient private var lastRecordedURL: URL? + @Transient private var lastRecordedTime: Date? + func updateHistory() { - if let historyManager = self.historyManager { - Task { @MainActor in - historyManager.record( - title: self.title, - url: self.url, - faviconURL: self.favicon, - faviconLocalFile: self.faviconLocalFile, - container: self.container - ) - } + guard let historyManager = self.historyManager else { return } + + // Skip history recording for special URLs + if url.scheme == "ora" || url.scheme == "about" { + return + } + + // Debounce: Don't record the same URL within 2 seconds + let now = Date() + if let lastURL = lastRecordedURL, + let lastTime = lastRecordedTime, + lastURL.absoluteString == url.absoluteString, + now.timeIntervalSince(lastTime) < 2.0 + { + return + } + + // Update tracking variables + lastRecordedURL = url + lastRecordedTime = now + + Task { @MainActor in + historyManager.record( + title: self.title, + url: self.url, + faviconURL: self.favicon, + faviconLocalFile: self.faviconLocalFile, + container: self.container, + isPrivate: self.isPrivate + ) } } diff --git a/ora/Modules/Browser/BrowserView.swift b/ora/Modules/Browser/BrowserView.swift index 25ad8a99..99dd38dd 100644 --- a/ora/Modules/Browser/BrowserView.swift +++ b/ora/Modules/Browser/BrowserView.swift @@ -205,7 +205,12 @@ struct BrowserView: View { )) } if let tab = tabManager.activeTab { - if tab.isWebViewReady { + // Show HistoryView for history tabs (identified by URL) + if tab.url.scheme == "ora", tab.url.host == "history" { + HistoryView() + .id(tab.id) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if tab.isWebViewReady { if tab.hasNavigationError, let error = tab.navigationError { StatusPageView( error: error, diff --git a/ora/Modules/Settings/Sections/GeneralSettingsView.swift b/ora/Modules/Settings/Sections/GeneralSettingsView.swift index 8b6c9770..52cf37b7 100644 --- a/ora/Modules/Settings/Sections/GeneralSettingsView.swift +++ b/ora/Modules/Settings/Sections/GeneralSettingsView.swift @@ -30,9 +30,8 @@ struct GeneralSettingsView: View { .padding(12) .background(theme.solidWindowBackgroundColor) .cornerRadius(8) - - if !defaultBrowserManager.isDefault { - + + if !defaultBrowserManager.isDefault { HStack { Text("Born for your Mac. Make Ora your default browser.") Spacer() diff --git a/ora/Modules/Sidebar/SidebarView.swift b/ora/Modules/Sidebar/SidebarView.swift index de86db16..11a9636d 100644 --- a/ora/Modules/Sidebar/SidebarView.swift +++ b/ora/Modules/Sidebar/SidebarView.swift @@ -11,8 +11,6 @@ struct SidebarView: View { @EnvironmentObject var privacyMode: PrivacyMode @EnvironmentObject var media: MediaController @Query var containers: [TabContainer] - @Query(filter: nil, sort: [.init(\History.lastAccessedAt, order: .reverse)]) var histories: - [History] private let columns = Array(repeating: GridItem(spacing: 10), count: 3) let isFullscreen: Bool diff --git a/ora/OraCommands.swift b/ora/OraCommands.swift index 45a30612..0b97d96f 100644 --- a/ora/OraCommands.swift +++ b/ora/OraCommands.swift @@ -42,6 +42,13 @@ struct OraCommands: Commands { NotificationCenter.default.post(name: .copyAddressURL, object: nil) } .keyboardShortcut(KeyboardShortcuts.Address.copyURL.keyboardShortcut) + + Divider() + + Button("Show History") { + NotificationCenter.default.post(name: .showHistory, object: NSApp.keyWindow) + } + .keyboardShortcut(KeyboardShortcuts.History.show.keyboardShortcut) } CommandGroup(replacing: .sidebar) { diff --git a/ora/OraRoot.swift b/ora/OraRoot.swift index 6f01d4de..a8f23873 100644 --- a/ora/OraRoot.swift +++ b/ora/OraRoot.swift @@ -177,6 +177,10 @@ struct OraRoot: View { tabManager.selectTabAtIndex(index) } } + NotificationCenter.default.addObserver(forName: .showHistory, object: nil, queue: .main) { note in + guard note.object as? NSWindow === window ?? NSApp.keyWindow else { return } + tabManager.openHistoryTab(historyManager: historyManager, downloadManager: downloadManager) + } NotificationCenter.default.addObserver(forName: .openURL, object: nil, queue: .main) { note in let targetWindow = window ?? NSApp.keyWindow if let sender = note.object as? NSWindow { diff --git a/ora/Services/DefaultBrowserManager.swift b/ora/Services/DefaultBrowserManager.swift index 67fdc55a..d993b8da 100644 --- a/ora/Services/DefaultBrowserManager.swift +++ b/ora/Services/DefaultBrowserManager.swift @@ -5,10 +5,9 @@ // Created by keni on 9/30/25. // - -import CoreServices import AppKit import Combine +import CoreServices class DefaultBrowserManager: ObservableObject { static let shared = DefaultBrowserManager() @@ -38,7 +37,8 @@ class DefaultBrowserManager: ObservableObject { static func checkIsDefault() -> Bool { guard let testURL = URL(string: "http://example.com"), let appURL = NSWorkspace.shared.urlForApplication(toOpen: testURL), - let appBundle = Bundle(url: appURL) else { + let appBundle = Bundle(url: appURL) + else { return false } diff --git a/ora/Services/HistoryManager.swift b/ora/Services/HistoryManager.swift index 87ed1714..84de1acb 100644 --- a/ora/Services/HistoryManager.swift +++ b/ora/Services/HistoryManager.swift @@ -19,38 +19,30 @@ class HistoryManager: ObservableObject { url: URL, faviconURL: URL? = nil, faviconLocalFile: URL? = nil, - container: TabContainer + container: TabContainer, + isPrivate: Bool = false ) { - let urlString = url.absoluteString + // Don't save history in private mode + guard !isPrivate else { + logger.debug("Skipping history recording - private mode") + return + } + let now = Date() - // Check if a history record already exists for this URL - let descriptor = FetchDescriptor( - predicate: #Predicate { - $0.urlString == urlString - }, - sortBy: [.init(\.lastAccessedAt, order: .reverse)] - ) + // Create a new history entry for each visit (no more consolidation) + let defaultFaviconURL = URL(string: "https://www.google.com/s2/favicons?domain=\(url.host ?? "google.com")") + let resolvedFaviconURL = faviconURL ?? defaultFaviconURL - if let existing = try? modelContext.fetch(descriptor).first { - existing.visitCount += 1 - existing.lastAccessedAt = Date() // update last visited time - } else { - let now = Date() - let defaultFaviconURL = URL(string: "https://www.google.com/s2/favicons?domain=\(url.host ?? "google.com")") - let fallbackURL = URL(fileURLWithPath: "") - let resolvedFaviconURL = faviconURL ?? defaultFaviconURL ?? fallbackURL - modelContext.insert(History( - url: url, - title: title, - faviconURL: resolvedFaviconURL, - faviconLocalFile: faviconLocalFile, - createdAt: now, - lastAccessedAt: now, - visitCount: 1, - container: container - )) - } + let historyEntry = History( + url: url, + title: title, + faviconURL: resolvedFaviconURL, + faviconLocalFile: faviconLocalFile, + visitedAt: now, + container: container + ) + modelContext.insert(historyEntry) try? modelContext.save() } @@ -60,8 +52,10 @@ class HistoryManager: ObservableObject { // Define the predicate for searching let predicate: Predicate if trimmedText.isEmpty { - // If the search text is empty, return all records - predicate = #Predicate { _ in true } + // If the search text is empty, return all records for the container + predicate = #Predicate { history in + history.container != nil && history.container!.id == activeContainerId + } } else { // Case-insensitive substring search on url and title predicate = #Predicate { history in @@ -71,10 +65,10 @@ class HistoryManager: ObservableObject { } } - // Create fetch descriptor with predicate and sorting + // Create fetch descriptor with predicate and sorting by visitedAt (most recent first) let descriptor = FetchDescriptor( predicate: predicate, - sortBy: [SortDescriptor(\.lastAccessedAt, order: .reverse)] + sortBy: [SortDescriptor(\.visitedAt, order: .reverse)] ) do { @@ -104,4 +98,70 @@ class HistoryManager: ObservableObject { logger.error("Failed to clear history for container \(container.id): \(error.localizedDescription)") } } + + // MARK: - Chronological History Methods + + func getChronologicalHistory(for containerId: UUID, limit: Int? = nil) -> [History] { + let containerDescriptor = FetchDescriptor( + predicate: #Predicate { $0.id == containerId } + ) + + do { + let containers = try modelContext.fetch(containerDescriptor) + if let container = containers.first { + // Filter out old migrated records without visitedAt timestamps and sort + let sortedHistory = container.history + .filter { $0.visitedAt != nil } + .sorted { first, second in + guard let date1 = first.visitedAt, let date2 = second.visitedAt else { return false } + return date1 > date2 + } + + // Apply limit if specified + let results = if let limit { + Array(sortedHistory.prefix(limit)) + } else { + sortedHistory + } + + return results + } + } catch { + logger.error("Error fetching container: \(error.localizedDescription)") + } + + return [] + } + + func searchChronologicalHistory(_ text: String, activeContainerId: UUID) -> [History] { + let trimmedText = text.trimmingCharacters(in: .whitespaces) + + // Get all history for the container first + let allHistory = getChronologicalHistory(for: activeContainerId) + + // If no search text, return all + if trimmedText.isEmpty { + return allHistory + } + + // Filter in memory for search text + let filteredHistory = allHistory.filter { history in + history.urlString.localizedStandardContains(trimmedText) || + history.title.localizedStandardContains(trimmedText) + } + + return filteredHistory + } + + func deleteHistory(_ history: History) { + modelContext.delete(history) + try? modelContext.save() + } + + func deleteHistories(_ histories: [History]) { + for history in histories { + modelContext.delete(history) + } + try? modelContext.save() + } } diff --git a/ora/Services/TabManager.swift b/ora/Services/TabManager.swift index 4ba09c32..2ee1b10a 100644 --- a/ora/Services/TabManager.swift +++ b/ora/Services/TabManager.swift @@ -57,7 +57,7 @@ class TabManager: ObservableObject { } func isActive(_ tab: Tab) -> Bool { - if let activeTab = self.activeTab { + if let activeTab { return activeTab.id == tab.id } return false @@ -87,12 +87,17 @@ class TabManager: ObservableObject { try? modelContext.save() } + func getActiveTab() -> Tab? { + return activeTab + } + // MARK: - Container Public API's func moveTabToContainer(_ tab: Tab, toContainer: TabContainer) { tab.container = toContainer try? modelContext.save() } + private func initializeActiveContainerAndTab() { // Ensure containers are fetched let containers = fetchContainers() @@ -116,11 +121,10 @@ class TabManager: ObservableObject { @discardableResult func createContainer(name: String = "Default", emoji: String = "•") -> TabContainer { - let newContainer = TabContainer(name: name, emoji: emoji) modelContext.insert(newContainer) activeContainer = newContainer - self.activeTab = nil + activeTab = nil try? modelContext.save() // _ = fetchContainers() // Refresh containers return newContainer @@ -187,9 +191,9 @@ class TabManager: ObservableObject { ) modelContext.insert(newTab) container.tabs.append(newTab) - activeTab?.maybeIsActive = false + activeTab?.maybeIsActive = false activeTab = newTab - activeTab?.maybeIsActive = true + activeTab?.maybeIsActive = true newTab.lastAccessedAt = Date() container.lastAccessedAt = Date() @@ -238,7 +242,7 @@ class TabManager: ObservableObject { container.tabs.append(newTab) if focusAfterOpening { - activeTab?.maybeIsActive = false + activeTab?.maybeIsActive = false activeTab = newTab activeTab?.maybeIsActive = true newTab.lastAccessedAt = Date() @@ -261,6 +265,55 @@ class TabManager: ObservableObject { } } + func openHistoryTab( + historyManager: HistoryManager, + downloadManager: DownloadManager? = nil + ) { + guard let container = activeContainer else { return } + + // Check if a history tab already exists (using URL-based identification) + if let existingHistoryTab = container.tabs.first(where: { + $0.url.scheme == "ora" && $0.url.host == "history" + }) { + // Switch to existing history tab + activeTab?.maybeIsActive = false + activeTab = existingHistoryTab + activeTab?.maybeIsActive = true + existingHistoryTab.lastAccessedAt = Date() + container.lastAccessedAt = Date() + try? modelContext.save() + return + } + + // Create new history tab as a normal tab + let historyURL = URL(string: "ora://history") ?? URL(fileURLWithPath: "") + let newTab = Tab( + url: historyURL, + title: "History", + favicon: nil, // We could add a history icon here + container: container, + type: .normal, // Just a normal tab with special URL + isPlayingMedia: false, + order: container.tabs.count + 1, + historyManager: historyManager, + downloadManager: downloadManager, + tabManager: self, + isPrivate: false // History tabs are never private + ) + + modelContext.insert(newTab) + container.tabs.append(newTab) + + // Switch to the new history tab + activeTab?.maybeIsActive = false + activeTab = newTab + activeTab?.maybeIsActive = true + newTab.lastAccessedAt = Date() + container.lastAccessedAt = Date() + + try? modelContext.save() + } + func reorderTabs(from: Tab, toTab: Tab) { from.container.reorderTabs(from: from, to: toTab) try? modelContext.save() @@ -273,22 +326,22 @@ class TabManager: ObservableObject { func closeTab(tab: Tab) { // If the closed tab was active, select another tab - if self.activeTab?.id == tab.id { + if activeTab?.id == tab.id { if let nextTab = tab.container.tabs .filter({ $0.id != tab.id && $0.isWebViewReady }) .sorted(by: { $0.lastAccessedAt ?? Date.distantPast > $1.lastAccessedAt ?? Date.distantPast }) .first { - self.activateTab(nextTab) + activateTab(nextTab) // } else if let nextContainer = containers.first(where: { $0.id != tab.container.id }) { // self.activateContainer(nextContainer) // } else { - self.activeTab = nil + activeTab = nil } } else { - self.activeTab = activeTab + activeTab = activeTab } if activeTab?.isWebViewReady != nil, let historyManager = tab.historyManager, let downloadManager = tab.downloadManager, let tabManager = tab.tabManager @@ -313,7 +366,7 @@ class TabManager: ObservableObject { try? self.modelContext.save() } } - self.activeTab?.maybeIsActive = true + activeTab?.maybeIsActive = true } func closeActiveTab() { @@ -354,7 +407,6 @@ class TabManager: ObservableObject { } } - func selectTabAtIndex(_ index: Int) { guard let container = activeContainer else { return } diff --git a/ora/UI/HighlightedText.swift b/ora/UI/HighlightedText.swift new file mode 100644 index 00000000..3f556eb5 --- /dev/null +++ b/ora/UI/HighlightedText.swift @@ -0,0 +1,87 @@ +import SwiftUI + +struct HighlightedText: View { + let text: String + let searchText: String + let font: Font + let primaryColor: Color + let highlightColor: Color + + init( + text: String, + searchText: String, + font: Font = .body, + primaryColor: Color = .primary, + highlightColor: Color = .yellow + ) { + self.text = text + self.searchText = searchText + self.font = font + self.primaryColor = primaryColor + self.highlightColor = highlightColor + } + + var body: some View { + if searchText.isEmpty { + Text(text) + .font(font) + .foregroundColor(primaryColor) + } else { + Text(buildAttributedString()) + .font(font) + } + } + + private func buildAttributedString() -> AttributedString { + var attributedString = AttributedString(text) + let searchQuery = searchText.lowercased() + let textLowercased = text.lowercased() + + var searchStartIndex = textLowercased.startIndex + + while let range = textLowercased.range(of: searchQuery, range: searchStartIndex ..< textLowercased.endIndex) { + let attributedRange = AttributedString + .Index(range.lowerBound, within: attributedString)! ..< AttributedString.Index( + range.upperBound, + within: attributedString + )! + + attributedString[attributedRange].backgroundColor = highlightColor + attributedString[attributedRange].foregroundColor = .black + + searchStartIndex = range.upperBound + } + + // Set the default color for non-highlighted text + attributedString.foregroundColor = primaryColor + + return attributedString + } +} + +#Preview { + VStack(spacing: 16) { + HighlightedText( + text: "This is a sample text with highlighting", + searchText: "sample", + font: .title, + primaryColor: .primary, + highlightColor: .yellow + ) + + HighlightedText( + text: "https://www.example.com/some/path", + searchText: "example", + font: .body, + primaryColor: .secondary, + highlightColor: .blue.opacity(0.3) + ) + + HighlightedText( + text: "No highlighting when search is empty", + searchText: "", + font: .body + ) + } + .padding() +} diff --git a/ora/UI/HistoryView.swift b/ora/UI/HistoryView.swift new file mode 100644 index 00000000..36e716f6 --- /dev/null +++ b/ora/UI/HistoryView.swift @@ -0,0 +1,472 @@ +import SwiftData +import SwiftUI + +struct HistoryView: View { + @Environment(\.theme) private var theme + @EnvironmentObject var historyManager: HistoryManager + @EnvironmentObject var tabManager: TabManager + @EnvironmentObject var privacyMode: PrivacyMode + + @State private var searchText = "" + @State private var selectedVisits: Set = [] + @State private var isSelectMode = false + @State private var showClearAllAlert = false + @State private var isHeaderHovered = false + + private var filteredVisits: [History] { + guard let containerId = tabManager.activeContainer?.id else { return [] } + + if searchText.trimmingCharacters(in: .whitespaces).isEmpty { + return historyManager.getChronologicalHistory(for: containerId) + } else { + return historyManager.searchChronologicalHistory(searchText, activeContainerId: containerId) + } + } + + // Group visits by date + private var groupedVisits: [(String, [History])] { + let calendar = Calendar.current + let today = Date() + + let grouped = Dictionary(grouping: filteredVisits) { visit in + guard let visitDate = visit.visitedAt else { return "Unknown" } + if calendar.isDate(visitDate, inSameDayAs: today) { + return "Today" + } else if calendar.isDate( + visitDate, + inSameDayAs: calendar.date(byAdding: .day, value: -1, to: today) ?? today + ) { + return "Yesterday" + } else { + let formatter = DateFormatter() + formatter.dateStyle = .full + formatter.timeStyle = .none + return formatter.string(from: visitDate) + } + } + + let sortedKeys = grouped.keys.sorted { key1, key2 in + if key1 == "Today" { return true } + if key2 == "Today" { return false } + if key1 == "Yesterday" { return true } + if key2 == "Yesterday" { return false } + + // For other dates, sort by actual date + let formatter = DateFormatter() + formatter.dateStyle = .full + formatter.timeStyle = .none + + let date1 = formatter.date(from: key1) ?? Date.distantPast + let date2 = formatter.date(from: key2) ?? Date.distantPast + return date1 > date2 + } + + return sortedKeys.compactMap { key in + guard let visits = grouped[key] else { return nil } + return (key, visits.sorted { + guard let date1 = $0.visitedAt, let date2 = $1.visitedAt else { return false } + return date1 > date2 + }) + } + } + + var body: some View { + if privacyMode.isPrivate { + HistoryViewPrivate() + } else { + VStack(spacing: 0) { + // Header with search and controls + VStack(spacing: 16) { + HStack { + Text("History") + .font(.largeTitle) + .fontWeight(.bold) + + Spacer() + + if !isSelectMode { + HStack(spacing: 12) { + // Clear All - styled as proper UI button + Button(action: { + showClearAllAlert = true + }) { + HStack(spacing: 6) { + Image(systemName: "trash") + .font(.system(size: 12, weight: .medium)) + Text("Clear All") + .font(.system(size: 13, weight: .medium)) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.red.opacity(0.1)) + .foregroundColor(.red) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.red.opacity(0.3), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .scaleEffect(isHeaderHovered ? 1.0 : 0.95) + .opacity(isHeaderHovered ? 1.0 : 0.7) + .animation(.easeInOut(duration: 0.2), value: isHeaderHovered) + + // Select - appears on hover + if isHeaderHovered || isSelectMode { + Button(action: { + withAnimation(.easeInOut(duration: 0.2)) { + isSelectMode = true + } + }) { + HStack(spacing: 6) { + Image(systemName: "checkmark.circle") + .font(.system(size: 12, weight: .medium)) + Text("Select") + .font(.system(size: 13, weight: .medium)) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(theme.accent.opacity(0.1)) + .foregroundColor(theme.accent) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(theme.accent.opacity(0.3), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .transition(.scale.combined(with: .opacity)) + } + } + } else { + HStack(spacing: 12) { + Button(action: { + withAnimation(.easeInOut(duration: 0.2)) { + isSelectMode = false + selectedVisits.removeAll() + } + }) { + HStack(spacing: 6) { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .medium)) + Text("Cancel") + .font(.system(size: 13, weight: .medium)) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.secondary.opacity(0.1)) + .foregroundColor(.secondary) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.secondary.opacity(0.3), lineWidth: 1) + ) + } + .buttonStyle(.plain) + + if !selectedVisits.isEmpty { + Button(action: { + deleteSelectedVisits() + }) { + HStack(spacing: 6) { + Image(systemName: "trash") + .font(.system(size: 12, weight: .medium)) + Text("Delete (\(selectedVisits.count))") + .font(.system(size: 13, weight: .medium)) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.red.opacity(0.1)) + .foregroundColor(.red) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.red.opacity(0.3), lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + } + } + } + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.2)) { + isHeaderHovered = hovering + } + } + + // Search bar + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + + TextField("Search history...", text: $searchText) + .textFieldStyle(.plain) + } + .padding(12) + .background(theme.background.opacity(0.5)) + .cornerRadius(8) + } + .padding() + + Divider() + + // History list + if groupedVisits.isEmpty { + VStack { + Spacer() + Image(systemName: "clock") + .font(.system(size: 48)) + .foregroundColor(.secondary) + Text("No history") + .font(.title2) + .foregroundColor(.secondary) + Text(searchText.isEmpty ? "Start browsing to see your history here" : "No results found") + .foregroundColor(.secondary) + Spacer() + } + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(groupedVisits, id: \.0) { dateGroup in + HistoryDateSection( + date: dateGroup.0, + visits: dateGroup.1, + searchText: searchText, + isSelectMode: isSelectMode, + selectedVisits: $selectedVisits, + onVisitTap: { visit in + openHistoryItem(visit) + }, + onVisitDelete: { visit in + deleteSingleVisit(visit) + } + ) + } + } + .padding(.horizontal) + } + } + + Spacer() + } + .background(theme.background) + .alert("Clear All History", isPresented: $showClearAllAlert) { + Button("Cancel", role: .cancel) {} + Button("Clear All", role: .destructive) { + clearAllHistory() + } + } message: { + Text("Are you sure you want to delete all browsing history? This action cannot be undone.") + } + } + } + + private func openHistoryItem(_ visit: History) { + guard !isSelectMode else { return } + + tabManager.openTab( + url: visit.url, + historyManager: historyManager, + isPrivate: privacyMode.isPrivate + ) + } + + private func deleteSelectedVisits() { + let visitsToDelete = filteredVisits.filter { selectedVisits.contains($0.id) } + historyManager.deleteHistories(visitsToDelete) + selectedVisits.removeAll() + isSelectMode = false + } + + private func clearAllHistory() { + guard let container = tabManager.activeContainer else { return } + historyManager.clearContainerHistory(container) + + // Exit select mode if active + if isSelectMode { + isSelectMode = false + selectedVisits.removeAll() + } + } + + private func deleteSingleVisit(_ visit: History) { + historyManager.deleteHistory(visit) + } +} + +struct HistoryDateSection: View { + @Environment(\.theme) private var theme + + let date: String + let visits: [History] + let searchText: String + let isSelectMode: Bool + @Binding var selectedVisits: Set + let onVisitTap: (History) -> Void + let onVisitDelete: (History) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + // Date header + HStack { + Text(date) + .font(.headline) + .foregroundColor(.primary) + Spacer() + Text("\(visits.count) \(visits.count == 1 ? "visit" : "visits")") + .font(.caption) + .foregroundColor(.secondary) + } + .padding(.vertical, 8) + + // Visit items + ForEach(visits, id: \.id) { visit in + HistoryRow( + visit: visit, + searchText: searchText, + isSelectMode: isSelectMode, + isSelected: selectedVisits.contains(visit.id), + onTap: { + if isSelectMode { + if selectedVisits.contains(visit.id) { + selectedVisits.remove(visit.id) + } else { + selectedVisits.insert(visit.id) + } + } else { + onVisitTap(visit) + } + }, + onDelete: { + onVisitDelete(visit) + } + ) + } + } + .padding(.bottom, 16) + } +} + +struct HistoryRow: View { + @Environment(\.theme) private var theme + + let visit: History + let searchText: String + let isSelectMode: Bool + let isSelected: Bool + let onTap: () -> Void + let onDelete: () -> Void + + private var timeFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.timeStyle = .short + return formatter + } + + var body: some View { + HStack(spacing: 12) { + // Selection checkbox + if isSelectMode { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundColor(isSelected ? .blue : .secondary) + } + + // Favicon + Group { + if let faviconLocalFile = visit.faviconLocalFile, + let image = NSImage(contentsOf: faviconLocalFile) + { + Image(nsImage: image) + .resizable() + } else { + Image(systemName: "globe") + .foregroundColor(.secondary) + } + } + .frame(width: 16, height: 16) + + // Content + VStack(alignment: .leading, spacing: 2) { + HighlightedText( + text: visit.title.isEmpty ? visit.urlString : visit.title, + searchText: searchText, + font: .system(size: 14, weight: .medium), + primaryColor: .primary, + highlightColor: theme.accent.opacity(0.3) + ) + .lineLimit(1) + + HStack { + HighlightedText( + text: visit.urlString, + searchText: searchText, + font: .system(size: 12), + primaryColor: .secondary, + highlightColor: theme.accent.opacity(0.3) + ) + .lineLimit(1) + + Spacer() + + Text(timeFormatter.string(from: visit.visitedAt ?? Date())) + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + } + + Spacer() + } + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(isSelected ? theme.accent.opacity(0.3) : Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(isSelected ? theme.accent : Color.clear, lineWidth: 1) + ) + .contentShape(Rectangle()) + .onTapGesture { + onTap() + } + .contextMenu { + Button("Open in New Tab") { + // TODO: Implement open in new tab + } + + Button("Copy URL") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(visit.urlString, forType: .string) + } + + Button("Delete This Visit") { + onDelete() + } + } + } +} + +#Preview { + do { + let historyContainer = try ModelContainer(for: History.self) + let tabContainer = try ModelContainer(for: Tab.self) + + return HistoryView() + .environmentObject(HistoryManager( + modelContainer: historyContainer, + modelContext: ModelContext(historyContainer) + )) + .environmentObject(TabManager( + modelContainer: tabContainer, + modelContext: ModelContext(tabContainer), + mediaController: MediaController() + )) + .environmentObject(PrivacyMode(isPrivate: false)) + .withTheme() + } catch { + return Text("Preview unavailable: \(error.localizedDescription)") + .foregroundColor(.red) + } +} diff --git a/ora/UI/HistoryViewPrivate.swift b/ora/UI/HistoryViewPrivate.swift new file mode 100644 index 00000000..a9378db4 --- /dev/null +++ b/ora/UI/HistoryViewPrivate.swift @@ -0,0 +1,61 @@ +import SwiftUI + +struct HistoryViewPrivate: View { + @Environment(\.theme) private var theme + + var body: some View { + VStack(spacing: 0) { + // Header + VStack(spacing: 16) { + HStack { + Text("History") + .font(.largeTitle) + .fontWeight(.bold) + + Spacer() + } + } + .padding() + + Divider() + + // Private browsing zero state + VStack(spacing: 16) { + Spacer() + + Image(systemName: "eye.slash") + .font(.system(size: 64)) + .foregroundColor(.secondary) + + VStack(spacing: 8) { + Text("Private Browsing") + .font(.title) + .fontWeight(.semibold) + .foregroundColor(.primary) + + Text("History is not saved in private browsing mode") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + Text("Your browsing activity won't be stored or visible in your history.") + .font(.callout) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.top, 8) + } + .padding(.horizontal, 32) + + Spacer() + } + + Spacer() + } + .background(theme.background) + } +} + +#Preview { + HistoryViewPrivate() + .withTheme() +} diff --git a/ora/oraApp.swift b/ora/oraApp.swift index 2197ae88..543f3045 100644 --- a/ora/oraApp.swift +++ b/ora/oraApp.swift @@ -9,6 +9,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { NSWindow.allowsAutomaticWindowTabbing = false AppearanceManager.shared.updateAppearance() } + func application(_ application: NSApplication, open urls: [URL]) { handleIncomingURLs(urls) } @@ -22,6 +23,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } return WindowFactory.makeMainWindow(rootView: OraRoot()) } + func handleIncomingURLs(_ urls: [URL]) { let window = getWindow()! for url in urls { @@ -59,11 +61,11 @@ class AppState: ObservableObject { @main struct OraApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - + // Shared model container that uses the same configuration as the main browser private let sharedModelContainer: ModelContainer? = - try? ModelConfiguration.createOraContainer(isPrivate: false) - + try? ModelConfiguration.createOraContainer(isPrivate: false) + var body: some Scene { WindowGroup(id: "normal") { OraRoot() @@ -74,7 +76,7 @@ struct OraApp: App { .windowStyle(.hiddenTitleBar) .windowResizability(.contentMinSize) .handlesExternalEvents(matching: []) - + WindowGroup("Private", id: "private") { OraRoot(isPrivate: true) .frame(minWidth: 500, minHeight: 360) @@ -84,7 +86,7 @@ struct OraApp: App { .windowStyle(.hiddenTitleBar) .windowResizability(.contentMinSize) .handlesExternalEvents(matching: []) - + Settings { if let sharedModelContainer { SettingsContentView()