From a33fef6005a85c01f8f0051bf2e73c746526cfa1 Mon Sep 17 00:00:00 2001 From: Chase Roossin Date: Sun, 28 Sep 2025 16:13:08 -0700 Subject: [PATCH 01/10] feat: working on history view --- ora/Common/Constants/AppEvents.swift | 1 + .../ModelConfiguration+Shared.swift | 4 +- ora/Models/History.swift | 3 + ora/Models/HistoryVisit.swift | 36 ++ ora/Modules/Browser/BrowserView.swift | 23 ++ ora/OraCommands.swift | 7 + ora/OraRoot.swift | 4 + ora/Services/HistoryManager.swift | 136 +++++++- ora/UI/HistoryView.swift | 326 ++++++++++++++++++ ora/oraApp.swift | 2 + 10 files changed, 532 insertions(+), 10 deletions(-) create mode 100644 ora/Models/HistoryVisit.swift create mode 100644 ora/UI/HistoryView.swift diff --git a/ora/Common/Constants/AppEvents.swift b/ora/Common/Constants/AppEvents.swift index 30d5c4be..32cb7a53 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/Extensions/ModelConfiguration+Shared.swift b/ora/Common/Extensions/ModelConfiguration+Shared.swift index fd0ee5c4..1a6d2220 100644 --- a/ora/Common/Extensions/ModelConfiguration+Shared.swift +++ b/ora/Common/Extensions/ModelConfiguration+Shared.swift @@ -9,7 +9,7 @@ extension ModelConfiguration { } else { return ModelConfiguration( "OraData", - schema: Schema([TabContainer.self, History.self, Download.self]), + schema: Schema([TabContainer.self, History.self, HistoryVisit.self, Download.self]), url: URL.applicationSupportDirectory.appending(path: "OraData.sqlite") ) } @@ -18,7 +18,7 @@ extension ModelConfiguration { /// Creates a ModelContainer using the standard Ora database configuration static func createOraContainer(isPrivate: Bool = false) throws -> ModelContainer { return try ModelContainer( - for: TabContainer.self, History.self, Download.self, + for: TabContainer.self, History.self, HistoryVisit.self, Download.self, configurations: oraDatabase(isPrivate: isPrivate) ) } diff --git a/ora/Models/History.swift b/ora/Models/History.swift index 042ed436..3d6e0b2d 100644 --- a/ora/Models/History.swift +++ b/ora/Models/History.swift @@ -16,6 +16,9 @@ final class History { @Relationship(inverse: \TabContainer.history) var container: TabContainer? + // Relationship to individual chronological visits + @Relationship var visits: [HistoryVisit] = [] + init( id: UUID = UUID(), url: URL, diff --git a/ora/Models/HistoryVisit.swift b/ora/Models/HistoryVisit.swift new file mode 100644 index 00000000..907565dd --- /dev/null +++ b/ora/Models/HistoryVisit.swift @@ -0,0 +1,36 @@ +import Foundation +import SwiftData + +// SwiftData model for individual chronological browsing history visits +@Model +final class HistoryVisit { + @Attribute(.unique) var id: UUID + var url: URL + var urlString: String + var title: String + var visitedAt: Date + var faviconURL: URL? + var faviconLocalFile: URL? + + // Relationship to consolidated history entry + @Relationship(inverse: \History.visits) var historyEntry: History? + + init( + id: UUID = UUID(), + url: URL, + title: String, + visitedAt: Date = Date(), + faviconURL: URL? = nil, + faviconLocalFile: URL? = nil, + historyEntry: History? = nil + ) { + self.id = id + self.url = url + self.urlString = url.absoluteString + self.title = title + self.visitedAt = visitedAt + self.faviconURL = faviconURL + self.faviconLocalFile = faviconLocalFile + self.historyEntry = historyEntry + } +} diff --git a/ora/Modules/Browser/BrowserView.swift b/ora/Modules/Browser/BrowserView.swift index 25ad8a99..fda44bc8 100644 --- a/ora/Modules/Browser/BrowserView.swift +++ b/ora/Modules/Browser/BrowserView.swift @@ -80,6 +80,29 @@ struct BrowserView: View { if appState.isFloatingTabSwitchVisible { FloatingTabSwitcher() } + + if appState.showHistory { + ZStack { + // Background overlay + Color.black.opacity(0.3) + .ignoresSafeArea(.all) + .onTapGesture { + appState.showHistory = false + } + + // History view + HistoryView() + .frame(maxWidth: 900, maxHeight: 700) + .background(theme.background) + .cornerRadius(12) + .shadow(radius: 20) + .transition(.asymmetric( + insertion: .scale(scale: 0.9).combined(with: .opacity), + removal: .scale(scale: 0.9).combined(with: .opacity) + )) + } + .zIndex(1000) + } } if sidebarVisibility.side == .primary { 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 05737650..ebea6adf 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 } + appState.showHistory = true + } } } } diff --git a/ora/Services/HistoryManager.swift b/ora/Services/HistoryManager.swift index 87ed1714..673ac151 100644 --- a/ora/Services/HistoryManager.swift +++ b/ora/Services/HistoryManager.swift @@ -22,24 +22,26 @@ class HistoryManager: ObservableObject { container: TabContainer ) { let urlString = url.absoluteString + let now = Date() - // Check if a history record already exists for this URL + // 1. Handle consolidated history (existing behavior) let descriptor = FetchDescriptor( - predicate: #Predicate { - $0.urlString == urlString + predicate: #Predicate { history in + history.urlString == urlString }, sortBy: [.init(\.lastAccessedAt, order: .reverse)] ) - if let existing = try? modelContext.fetch(descriptor).first { + let consolidatedHistory: History + if let existing = try? modelContext.fetch(descriptor).first(where: { $0.container?.id == container.id }) { existing.visitCount += 1 - existing.lastAccessedAt = Date() // update last visited time + existing.lastAccessedAt = now + consolidatedHistory = existing } 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( + consolidatedHistory = History( url: url, title: title, faviconURL: resolvedFaviconURL, @@ -48,9 +50,24 @@ class HistoryManager: ObservableObject { lastAccessedAt: now, visitCount: 1, container: container - )) + ) + modelContext.insert(consolidatedHistory) } + // 2. Always create a new chronological visit entry + let visit = HistoryVisit( + url: url, + title: title, + visitedAt: now, + faviconURL: faviconURL, + faviconLocalFile: faviconLocalFile, + historyEntry: consolidatedHistory + ) + modelContext.insert(visit) + + // 3. Add visit to the consolidated history's visits array + consolidatedHistory.visits.append(visit) + try? modelContext.save() } @@ -92,16 +109,119 @@ class HistoryManager: ObservableObject { predicate: #Predicate { $0.container?.id == containerId } ) + // Fetch all visits and filter in-memory to avoid force unwraps + let visitDescriptor = FetchDescriptor() + do { let histories = try modelContext.fetch(descriptor) + let allVisits = try modelContext.fetch(visitDescriptor) + + // Filter visits safely without force unwraps + let visitsToDelete = allVisits.filter { visit in + guard let historyEntry = visit.historyEntry, + let visitContainer = historyEntry.container + else { + return false + } + return visitContainer.id == containerId + } for history in histories { modelContext.delete(history) } + for visit in visitsToDelete { + modelContext.delete(visit) + } + try modelContext.save() } catch { logger.error("Failed to clear history for container \(container.id): \(error.localizedDescription)") } } + + // MARK: - Chronological History Methods + + func getChronologicalHistory(for containerId: UUID, limit: Int? = nil) -> [HistoryVisit] { + // Fetch all visits first, then filter in-memory to avoid SwiftData predicate limitations + var descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.visitedAt, order: .reverse)] + ) + + if let limit { + descriptor.fetchLimit = limit * 3 // Fetch more to account for filtering + } + + do { + let allVisits = try modelContext.fetch(descriptor) + let filteredVisits = allVisits.filter { visit in + guard let historyEntry = visit.historyEntry, + let container = historyEntry.container + else { + return false + } + return container.id == containerId + } + + // Apply limit after filtering if specified + if let limit { + return Array(filteredVisits.prefix(limit)) + } + return filteredVisits + } catch { + logger.error("Error fetching chronological history: \(error.localizedDescription)") + return [] + } + } + + func searchChronologicalHistory(_ text: String, activeContainerId: UUID) -> [HistoryVisit] { + let trimmedText = text.trimmingCharacters(in: .whitespaces) + + // Fetch all visits first, then filter in-memory for better safety and flexibility + let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.visitedAt, order: .reverse)] + ) + + do { + let allVisits = try modelContext.fetch(descriptor) + let filteredVisits = allVisits.filter { visit in + // Defensive filtering for container match + guard let historyEntry = visit.historyEntry, + let container = historyEntry.container + else { + return false + } + + let containerMatches = container.id == activeContainerId + + // If no search text, just return container matches + if trimmedText.isEmpty { + return containerMatches + } + + // Check if text matches URL or title + let textMatches = visit.urlString.localizedStandardContains(trimmedText) || + visit.title.localizedStandardContains(trimmedText) + + return containerMatches && textMatches + } + + return filteredVisits + } catch { + logger.error("Error searching chronological history: \(error.localizedDescription)") + return [] + } + } + + func deleteHistoryVisit(_ visit: HistoryVisit) { + modelContext.delete(visit) + try? modelContext.save() + } + + func deleteHistoryVisits(_ visits: [HistoryVisit]) { + for visit in visits { + modelContext.delete(visit) + } + try? modelContext.save() + } } diff --git a/ora/UI/HistoryView.swift b/ora/UI/HistoryView.swift new file mode 100644 index 00000000..5d0ed0b8 --- /dev/null +++ b/ora/UI/HistoryView.swift @@ -0,0 +1,326 @@ +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 + + private var filteredVisits: [HistoryVisit] { + 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, [HistoryVisit])] { + let calendar = Calendar.current + let today = Date() + + let grouped = Dictionary(grouping: filteredVisits) { visit in + if calendar.isDate(visit.visitedAt, inSameDayAs: today) { + return "Today" + } else if calendar.isDate( + visit.visitedAt, + 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: visit.visitedAt) + } + } + + 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 { $0.visitedAt > $1.visitedAt }) + } + } + + var body: some View { + NavigationView { + VStack(spacing: 0) { + // Header with search and controls + VStack(spacing: 16) { + HStack { + Text("Browsing History") + .font(.largeTitle) + .fontWeight(.bold) + + Spacer() + + if !isSelectMode { + Button("Select") { + isSelectMode = true + } + .buttonStyle(.plain) + } else { + HStack { + Button("Cancel") { + isSelectMode = false + selectedVisits.removeAll() + } + .buttonStyle(.plain) + + if !selectedVisits.isEmpty { + Button("Delete Selected (\(selectedVisits.count))") { + deleteSelectedVisits() + } + .foregroundColor(.red) + .buttonStyle(.plain) + } + } + } + } + + // 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 browsing 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, + isSelectMode: isSelectMode, + selectedVisits: $selectedVisits, + onVisitTap: { visit in + openHistoryItem(visit) + } + ) + } + } + .padding(.horizontal) + } + } + + Spacer() + } + .background(theme.background) + } + } + + private func openHistoryItem(_ visit: HistoryVisit) { + 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.deleteHistoryVisits(visitsToDelete) + selectedVisits.removeAll() + isSelectMode = false + } +} + +struct HistoryDateSection: View { + @Environment(\.theme) private var theme + + let date: String + let visits: [HistoryVisit] + let isSelectMode: Bool + @Binding var selectedVisits: Set + let onVisitTap: (HistoryVisit) -> 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 + HistoryVisitRow( + visit: visit, + 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) + } + } + ) + } + } + .padding(.bottom, 16) + } +} + +struct HistoryVisitRow: View { + @Environment(\.theme) private var theme + + let visit: HistoryVisit + let isSelectMode: Bool + let isSelected: Bool + let onTap: () -> 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) { + Text(visit.title.isEmpty ? visit.urlString : visit.title) + .font(.system(size: 14, weight: .medium)) + .lineLimit(1) + .foregroundColor(.primary) + + HStack { + Text(visit.urlString) + .font(.system(size: 12)) + .foregroundColor(.secondary) + .lineLimit(1) + + Spacer() + + Text(timeFormatter.string(from: visit.visitedAt)) + .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") { + // TODO: Implement single visit deletion + } + } + } +} + +#Preview { + HistoryView() + .environmentObject(HistoryManager( + modelContainer: try! ModelContainer(for: History.self, HistoryVisit.self), + modelContext: ModelContext(try! ModelContainer(for: History.self, HistoryVisit.self)) + )) + .environmentObject(TabManager( + modelContainer: try! ModelContainer(for: Tab.self), + modelContext: ModelContext(try! ModelContainer(for: Tab.self)), + mediaController: MediaController() + )) + .environmentObject(PrivacyMode(isPrivate: false)) + .withTheme() +} diff --git a/ora/oraApp.swift b/ora/oraApp.swift index f73c72e4..cc05ff4f 100644 --- a/ora/oraApp.swift +++ b/ora/oraApp.swift @@ -33,6 +33,8 @@ class AppState: ObservableObject { @Published var showFullURL: Bool = (UserDefaults.standard.object(forKey: "showFullURL") as? Bool) ?? true { didSet { UserDefaults.standard.set(showFullURL, forKey: "showFullURL") } } + + @Published var showHistory: Bool = false } @main From 5e4f751832a2d21bee523572ef23ade7c54ce01e Mon Sep 17 00:00:00 2001 From: Chase Roossin Date: Sun, 28 Sep 2025 16:34:34 -0700 Subject: [PATCH 02/10] feat: move history to normal tab --- ora/Modules/Browser/BrowserView.swift | 30 ++---- ora/OraRoot.swift | 2 +- ora/Services/TabManager.swift | 49 +++++++++ ora/UI/HistoryView.swift | 144 +++++++++++++------------- ora/oraApp.swift | 2 - 5 files changed, 127 insertions(+), 100 deletions(-) diff --git a/ora/Modules/Browser/BrowserView.swift b/ora/Modules/Browser/BrowserView.swift index fda44bc8..99dd38dd 100644 --- a/ora/Modules/Browser/BrowserView.swift +++ b/ora/Modules/Browser/BrowserView.swift @@ -80,29 +80,6 @@ struct BrowserView: View { if appState.isFloatingTabSwitchVisible { FloatingTabSwitcher() } - - if appState.showHistory { - ZStack { - // Background overlay - Color.black.opacity(0.3) - .ignoresSafeArea(.all) - .onTapGesture { - appState.showHistory = false - } - - // History view - HistoryView() - .frame(maxWidth: 900, maxHeight: 700) - .background(theme.background) - .cornerRadius(12) - .shadow(radius: 20) - .transition(.asymmetric( - insertion: .scale(scale: 0.9).combined(with: .opacity), - removal: .scale(scale: 0.9).combined(with: .opacity) - )) - } - .zIndex(1000) - } } if sidebarVisibility.side == .primary { @@ -228,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/OraRoot.swift b/ora/OraRoot.swift index ebea6adf..ce821e25 100644 --- a/ora/OraRoot.swift +++ b/ora/OraRoot.swift @@ -179,7 +179,7 @@ struct OraRoot: View { } NotificationCenter.default.addObserver(forName: .showHistory, object: nil, queue: .main) { note in guard note.object as? NSWindow === window ?? NSApp.keyWindow else { return } - appState.showHistory = true + tabManager.openHistoryTab(historyManager: historyManager, downloadManager: downloadManager) } } } diff --git a/ora/Services/TabManager.swift b/ora/Services/TabManager.swift index c18bb8e9..7b0f9ffc 100644 --- a/ora/Services/TabManager.swift +++ b/ora/Services/TabManager.swift @@ -290,6 +290,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: "Browser 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() diff --git a/ora/UI/HistoryView.swift b/ora/UI/HistoryView.swift index 5d0ed0b8..c7bb1655 100644 --- a/ora/UI/HistoryView.swift +++ b/ora/UI/HistoryView.swift @@ -65,94 +65,92 @@ struct HistoryView: View { } var body: some View { - NavigationView { - VStack(spacing: 0) { - // Header with search and controls - VStack(spacing: 16) { - HStack { - Text("Browsing History") - .font(.largeTitle) - .fontWeight(.bold) - - Spacer() - - if !isSelectMode { - Button("Select") { - isSelectMode = true + VStack(spacing: 0) { + // Header with search and controls + VStack(spacing: 16) { + HStack { + Text("Browsing History") + .font(.largeTitle) + .fontWeight(.bold) + + Spacer() + + if !isSelectMode { + Button("Select") { + isSelectMode = true + } + .buttonStyle(.plain) + } else { + HStack { + Button("Cancel") { + isSelectMode = false + selectedVisits.removeAll() } .buttonStyle(.plain) - } else { - HStack { - Button("Cancel") { - isSelectMode = false - selectedVisits.removeAll() - } - .buttonStyle(.plain) - if !selectedVisits.isEmpty { - Button("Delete Selected (\(selectedVisits.count))") { - deleteSelectedVisits() - } - .foregroundColor(.red) - .buttonStyle(.plain) + if !selectedVisits.isEmpty { + Button("Delete Selected (\(selectedVisits.count))") { + deleteSelectedVisits() } + .foregroundColor(.red) + .buttonStyle(.plain) } } } + } - // Search bar - HStack { - Image(systemName: "magnifyingglass") - .foregroundColor(.secondary) + // 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) + TextField("Search history...", text: $searchText) + .textFieldStyle(.plain) } - .padding() - - Divider() - - // History list - if groupedVisits.isEmpty { - VStack { - Spacer() - Image(systemName: "clock") - .font(.system(size: 48)) - .foregroundColor(.secondary) - Text("No browsing 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, - isSelectMode: isSelectMode, - selectedVisits: $selectedVisits, - onVisitTap: { visit in - openHistoryItem(visit) - } - ) - } + .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 browsing 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, + isSelectMode: isSelectMode, + selectedVisits: $selectedVisits, + onVisitTap: { visit in + openHistoryItem(visit) + } + ) } - .padding(.horizontal) } + .padding(.horizontal) } - - Spacer() } - .background(theme.background) + + Spacer() } + .background(theme.background) } private func openHistoryItem(_ visit: HistoryVisit) { diff --git a/ora/oraApp.swift b/ora/oraApp.swift index cc05ff4f..f73c72e4 100644 --- a/ora/oraApp.swift +++ b/ora/oraApp.swift @@ -33,8 +33,6 @@ class AppState: ObservableObject { @Published var showFullURL: Bool = (UserDefaults.standard.object(forKey: "showFullURL") as? Bool) ?? true { didSet { UserDefaults.standard.set(showFullURL, forKey: "showFullURL") } } - - @Published var showHistory: Bool = false } @main From 321634853febd87229cf76c038612c47047c3429 Mon Sep 17 00:00:00 2001 From: Chase Roossin Date: Sun, 28 Sep 2025 16:40:46 -0700 Subject: [PATCH 03/10] feat: add clear all for history view --- ora/Models/Tab.swift | 42 ++++++++++++++++++++++++++++++---------- ora/UI/HistoryView.swift | 34 +++++++++++++++++++++++++++++--- 2 files changed, 63 insertions(+), 13 deletions(-) diff --git a/ora/Models/Tab.swift b/ora/Models/Tab.swift index bed015f5..5c6cd874 100644 --- a/ora/Models/Tab.swift +++ b/ora/Models/Tab.swift @@ -168,17 +168,39 @@ 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 + ) } } diff --git a/ora/UI/HistoryView.swift b/ora/UI/HistoryView.swift index c7bb1655..b34c2578 100644 --- a/ora/UI/HistoryView.swift +++ b/ora/UI/HistoryView.swift @@ -10,6 +10,7 @@ struct HistoryView: View { @State private var searchText = "" @State private var selectedVisits: Set = [] @State private var isSelectMode = false + @State private var showClearAllAlert = false private var filteredVisits: [HistoryVisit] { guard let containerId = tabManager.activeContainer?.id else { return [] } @@ -76,10 +77,18 @@ struct HistoryView: View { Spacer() if !isSelectMode { - Button("Select") { - isSelectMode = true + HStack(spacing: 12) { + Button("Clear All") { + showClearAllAlert = true + } + .foregroundColor(.red) + .buttonStyle(.plain) + + Button("Select") { + isSelectMode = true + } + .buttonStyle(.plain) } - .buttonStyle(.plain) } else { HStack { Button("Cancel") { @@ -151,6 +160,14 @@ struct HistoryView: View { 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: HistoryVisit) { @@ -169,6 +186,17 @@ struct HistoryView: View { 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() + } + } } struct HistoryDateSection: View { From 4550b85bf2234b9b180f8c667f6385b3921a552e Mon Sep 17 00:00:00 2001 From: Chase Roossin Date: Sun, 28 Sep 2025 16:48:49 -0700 Subject: [PATCH 04/10] feat: add highlighting on search --- ora/Services/TabManager.swift | 24 +++++----- ora/UI/HighlightedText.swift | 87 +++++++++++++++++++++++++++++++++++ ora/UI/HistoryView.swift | 28 +++++++---- 3 files changed, 119 insertions(+), 20 deletions(-) create mode 100644 ora/UI/HighlightedText.swift diff --git a/ora/Services/TabManager.swift b/ora/Services/TabManager.swift index 7b0f9ffc..8d16edfa 100644 --- a/ora/Services/TabManager.swift +++ b/ora/Services/TabManager.swift @@ -107,7 +107,7 @@ class TabManager: ObservableObject { } func isActive(_ tab: Tab) -> Bool { - if let activeTab = self.activeTab { + if let activeTab { return activeTab.id == tab.id } return false @@ -138,7 +138,7 @@ class TabManager: ObservableObject { } func getActiveTab() -> Tab? { - return self.activeTab + return activeTab } func moveTabToContainer(_ tab: Tab, toContainer: TabContainer) { @@ -172,7 +172,7 @@ class TabManager: ObservableObject { 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 @@ -216,9 +216,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() @@ -267,7 +267,7 @@ class TabManager: ObservableObject { container.tabs.append(newTab) if focusAfterOpening { - activeTab?.maybeIsActive = false + activeTab?.maybeIsActive = false activeTab = newTab activeTab?.maybeIsActive = true newTab.lastAccessedAt = Date() @@ -314,7 +314,7 @@ class TabManager: ObservableObject { let historyURL = URL(string: "ora://history") ?? URL(fileURLWithPath: "") let newTab = Tab( url: historyURL, - title: "Browser History", + title: "History", favicon: nil, // We could add a history icon here container: container, type: .normal, // Just a normal tab with special URL @@ -351,22 +351,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 @@ -391,7 +391,7 @@ class TabManager: ObservableObject { try? self.modelContext.save() } } - self.activeTab?.maybeIsActive = true + activeTab?.maybeIsActive = true } func closeActiveTab() { 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 index b34c2578..5d6ac53a 100644 --- a/ora/UI/HistoryView.swift +++ b/ora/UI/HistoryView.swift @@ -145,6 +145,7 @@ struct HistoryView: View { HistoryDateSection( date: dateGroup.0, visits: dateGroup.1, + searchText: searchText, isSelectMode: isSelectMode, selectedVisits: $selectedVisits, onVisitTap: { visit in @@ -204,6 +205,7 @@ struct HistoryDateSection: View { let date: String let visits: [HistoryVisit] + let searchText: String let isSelectMode: Bool @Binding var selectedVisits: Set let onVisitTap: (HistoryVisit) -> Void @@ -226,6 +228,7 @@ struct HistoryDateSection: View { ForEach(visits, id: \.id) { visit in HistoryVisitRow( visit: visit, + searchText: searchText, isSelectMode: isSelectMode, isSelected: selectedVisits.contains(visit.id), onTap: { @@ -250,6 +253,7 @@ struct HistoryVisitRow: View { @Environment(\.theme) private var theme let visit: HistoryVisit + let searchText: String let isSelectMode: Bool let isSelected: Bool let onTap: () -> Void @@ -284,16 +288,24 @@ struct HistoryVisitRow: View { // Content VStack(alignment: .leading, spacing: 2) { - Text(visit.title.isEmpty ? visit.urlString : visit.title) - .font(.system(size: 14, weight: .medium)) - .lineLimit(1) - .foregroundColor(.primary) + 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 { - Text(visit.urlString) - .font(.system(size: 12)) - .foregroundColor(.secondary) - .lineLimit(1) + HighlightedText( + text: visit.urlString, + searchText: searchText, + font: .system(size: 12), + primaryColor: .secondary, + highlightColor: theme.accent.opacity(0.3) + ) + .lineLimit(1) Spacer() From c364e356d1edcce39bab623558c55fd6f3dab5c3 Mon Sep 17 00:00:00 2001 From: Chase Roossin Date: Sun, 28 Sep 2025 16:55:26 -0700 Subject: [PATCH 05/10] feat: dont save history in private mode --- ora/Models/Tab.swift | 3 +- ora/Services/HistoryManager.swift | 5 +- ora/UI/HistoryView.swift | 178 +++++++++++++++--------------- ora/UI/HistoryViewPrivate.swift | 61 ++++++++++ 4 files changed, 158 insertions(+), 89 deletions(-) create mode 100644 ora/UI/HistoryViewPrivate.swift diff --git a/ora/Models/Tab.swift b/ora/Models/Tab.swift index 5c6cd874..ec0b50fa 100644 --- a/ora/Models/Tab.swift +++ b/ora/Models/Tab.swift @@ -199,7 +199,8 @@ class Tab: ObservableObject, Identifiable { url: self.url, faviconURL: self.favicon, faviconLocalFile: self.faviconLocalFile, - container: self.container + container: self.container, + isPrivate: self.isPrivate ) } } diff --git a/ora/Services/HistoryManager.swift b/ora/Services/HistoryManager.swift index 673ac151..cef9e28a 100644 --- a/ora/Services/HistoryManager.swift +++ b/ora/Services/HistoryManager.swift @@ -19,8 +19,11 @@ class HistoryManager: ObservableObject { url: URL, faviconURL: URL? = nil, faviconLocalFile: URL? = nil, - container: TabContainer + container: TabContainer, + isPrivate: Bool = false ) { + // Don't save history in private mode + guard !isPrivate else { return } let urlString = url.absoluteString let now = Date() diff --git a/ora/UI/HistoryView.swift b/ora/UI/HistoryView.swift index 5d6ac53a..fd732237 100644 --- a/ora/UI/HistoryView.swift +++ b/ora/UI/HistoryView.swift @@ -66,108 +66,112 @@ struct HistoryView: View { } var body: some View { - VStack(spacing: 0) { - // Header with search and controls - VStack(spacing: 16) { - HStack { - Text("Browsing History") - .font(.largeTitle) - .fontWeight(.bold) - - Spacer() - - if !isSelectMode { - HStack(spacing: 12) { - Button("Clear All") { - showClearAllAlert = true - } - .foregroundColor(.red) - .buttonStyle(.plain) + if privacyMode.isPrivate { + HistoryViewPrivate() + } else { + VStack(spacing: 0) { + // Header with search and controls + VStack(spacing: 16) { + HStack { + Text("Browsing History") + .font(.largeTitle) + .fontWeight(.bold) + + Spacer() + + if !isSelectMode { + HStack(spacing: 12) { + Button("Clear All") { + showClearAllAlert = true + } + .foregroundColor(.red) + .buttonStyle(.plain) - Button("Select") { - isSelectMode = true - } - .buttonStyle(.plain) - } - } else { - HStack { - Button("Cancel") { - isSelectMode = false - selectedVisits.removeAll() + Button("Select") { + isSelectMode = true + } + .buttonStyle(.plain) } - .buttonStyle(.plain) - - if !selectedVisits.isEmpty { - Button("Delete Selected (\(selectedVisits.count))") { - deleteSelectedVisits() + } else { + HStack { + Button("Cancel") { + isSelectMode = false + selectedVisits.removeAll() } - .foregroundColor(.red) .buttonStyle(.plain) + + if !selectedVisits.isEmpty { + Button("Delete Selected (\(selectedVisits.count))") { + deleteSelectedVisits() + } + .foregroundColor(.red) + .buttonStyle(.plain) + } } } } - } - - // 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() + // Search bar + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) - // History list - if groupedVisits.isEmpty { - VStack { - Spacer() - Image(systemName: "clock") - .font(.system(size: 48)) - .foregroundColor(.secondary) - Text("No browsing history") - .font(.title2) - .foregroundColor(.secondary) - Text(searchText.isEmpty ? "Start browsing to see your history here" : "No results found") - .foregroundColor(.secondary) - Spacer() + TextField("Search history...", text: $searchText) + .textFieldStyle(.plain) + } + .padding(12) + .background(theme.background.opacity(0.5)) + .cornerRadius(8) } - } 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) - } - ) + .padding() + + Divider() + + // History list + if groupedVisits.isEmpty { + VStack { + Spacer() + Image(systemName: "clock") + .font(.system(size: 48)) + .foregroundColor(.secondary) + Text("No browsing 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) + } + ) + } } + .padding(.horizontal) } - .padding(.horizontal) } - } - Spacer() - } - .background(theme.background) - .alert("Clear All History", isPresented: $showClearAllAlert) { - Button("Cancel", role: .cancel) {} - Button("Clear All", role: .destructive) { - clearAllHistory() + 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.") } - } message: { - Text("Are you sure you want to delete all browsing history? This action cannot be undone.") } } diff --git a/ora/UI/HistoryViewPrivate.swift b/ora/UI/HistoryViewPrivate.swift new file mode 100644 index 00000000..da236a81 --- /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("Browsing 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() +} From 63b19bc59d9a5ab47ba8b4d06177ac68506398c0 Mon Sep 17 00:00:00 2001 From: Chase Roossin Date: Sun, 28 Sep 2025 17:02:01 -0700 Subject: [PATCH 06/10] feat: update deletion ui --- ora/UI/HistoryView.swift | 122 ++++++++++++++++++++++++++++---- ora/UI/HistoryViewPrivate.swift | 2 +- 2 files changed, 108 insertions(+), 16 deletions(-) diff --git a/ora/UI/HistoryView.swift b/ora/UI/HistoryView.swift index fd732237..4d2fbd2f 100644 --- a/ora/UI/HistoryView.swift +++ b/ora/UI/HistoryView.swift @@ -11,6 +11,7 @@ struct HistoryView: View { @State private var selectedVisits: Set = [] @State private var isSelectMode = false @State private var showClearAllAlert = false + @State private var isHeaderHovered = false private var filteredVisits: [HistoryVisit] { guard let containerId = tabManager.activeContainer?.id else { return [] } @@ -73,7 +74,7 @@ struct HistoryView: View { // Header with search and controls VStack(spacing: 16) { HStack { - Text("Browsing History") + Text("History") .font(.largeTitle) .fontWeight(.bold) @@ -81,35 +82,114 @@ struct HistoryView: View { if !isSelectMode { HStack(spacing: 12) { - Button("Clear All") { + // 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) + ) } - .foregroundColor(.red) .buttonStyle(.plain) - - Button("Select") { - isSelectMode = true + .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)) } - .buttonStyle(.plain) } } else { - HStack { - Button("Cancel") { - isSelectMode = false - selectedVisits.removeAll() + 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("Delete Selected (\(selectedVisits.count))") { + 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) + ) } - .foregroundColor(.red) .buttonStyle(.plain) } } } } + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.2)) { + isHeaderHovered = hovering + } + } // Search bar HStack { @@ -134,7 +214,7 @@ struct HistoryView: View { Image(systemName: "clock") .font(.system(size: 48)) .foregroundColor(.secondary) - Text("No browsing history") + Text("No history") .font(.title2) .foregroundColor(.secondary) Text(searchText.isEmpty ? "Start browsing to see your history here" : "No results found") @@ -153,6 +233,9 @@ struct HistoryView: View { selectedVisits: $selectedVisits, onVisitTap: { visit in openHistoryItem(visit) + }, + onVisitDelete: { visit in + deleteSingleVisit(visit) } ) } @@ -202,6 +285,10 @@ struct HistoryView: View { selectedVisits.removeAll() } } + + private func deleteSingleVisit(_ visit: HistoryVisit) { + historyManager.deleteHistoryVisit(visit) + } } struct HistoryDateSection: View { @@ -213,6 +300,7 @@ struct HistoryDateSection: View { let isSelectMode: Bool @Binding var selectedVisits: Set let onVisitTap: (HistoryVisit) -> Void + let onVisitDelete: (HistoryVisit) -> Void var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -245,6 +333,9 @@ struct HistoryDateSection: View { } else { onVisitTap(visit) } + }, + onDelete: { + onVisitDelete(visit) } ) } @@ -261,6 +352,7 @@ struct HistoryVisitRow: View { let isSelectMode: Bool let isSelected: Bool let onTap: () -> Void + let onDelete: () -> Void private var timeFormatter: DateFormatter { let formatter = DateFormatter() @@ -346,7 +438,7 @@ struct HistoryVisitRow: View { } Button("Delete This Visit") { - // TODO: Implement single visit deletion + onDelete() } } } diff --git a/ora/UI/HistoryViewPrivate.swift b/ora/UI/HistoryViewPrivate.swift index da236a81..a9378db4 100644 --- a/ora/UI/HistoryViewPrivate.swift +++ b/ora/UI/HistoryViewPrivate.swift @@ -8,7 +8,7 @@ struct HistoryViewPrivate: View { // Header VStack(spacing: 16) { HStack { - Text("Browsing History") + Text("History") .font(.largeTitle) .fontWeight(.bold) From ae16c984611cca108a4e0f78815eb26aa5438795 Mon Sep 17 00:00:00 2001 From: Chase Roossin Date: Sun, 28 Sep 2025 18:48:00 -0700 Subject: [PATCH 07/10] fix: swiftlint in history view --- ora/UI/HistoryView.swift | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/ora/UI/HistoryView.swift b/ora/UI/HistoryView.swift index 4d2fbd2f..5139d38a 100644 --- a/ora/UI/HistoryView.swift +++ b/ora/UI/HistoryView.swift @@ -445,16 +445,24 @@ struct HistoryVisitRow: View { } #Preview { - HistoryView() - .environmentObject(HistoryManager( - modelContainer: try! ModelContainer(for: History.self, HistoryVisit.self), - modelContext: ModelContext(try! ModelContainer(for: History.self, HistoryVisit.self)) - )) - .environmentObject(TabManager( - modelContainer: try! ModelContainer(for: Tab.self), - modelContext: ModelContext(try! ModelContainer(for: Tab.self)), - mediaController: MediaController() - )) - .environmentObject(PrivacyMode(isPrivate: false)) - .withTheme() + do { + let historyContainer = try ModelContainer(for: History.self, HistoryVisit.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) + } } From 0000e9a2307bad38e90f72b0696d98b9e3ec99a8 Mon Sep 17 00:00:00 2001 From: Chase Roossin Date: Sun, 5 Oct 2025 06:49:37 -0700 Subject: [PATCH 08/10] fix: align on leveraging History model --- .../ModelConfiguration+Shared.swift | 4 +- ora/Models/History.swift | 20 +- ora/Models/HistoryVisit.swift | 36 ---- ora/Modules/Sidebar/SidebarView.swift | 2 - ora/Services/HistoryManager.swift | 172 +++++------------- ora/UI/HistoryView.swift | 26 +-- 6 files changed, 70 insertions(+), 190 deletions(-) delete mode 100644 ora/Models/HistoryVisit.swift diff --git a/ora/Common/Extensions/ModelConfiguration+Shared.swift b/ora/Common/Extensions/ModelConfiguration+Shared.swift index 1a6d2220..fd0ee5c4 100644 --- a/ora/Common/Extensions/ModelConfiguration+Shared.swift +++ b/ora/Common/Extensions/ModelConfiguration+Shared.swift @@ -9,7 +9,7 @@ extension ModelConfiguration { } else { return ModelConfiguration( "OraData", - schema: Schema([TabContainer.self, History.self, HistoryVisit.self, Download.self]), + schema: Schema([TabContainer.self, History.self, Download.self]), url: URL.applicationSupportDirectory.appending(path: "OraData.sqlite") ) } @@ -18,7 +18,7 @@ extension ModelConfiguration { /// Creates a ModelContainer using the standard Ora database configuration static func createOraContainer(isPrivate: Bool = false) throws -> ModelContainer { return try ModelContainer( - for: TabContainer.self, History.self, HistoryVisit.self, Download.self, + for: TabContainer.self, History.self, Download.self, configurations: oraDatabase(isPrivate: isPrivate) ) } diff --git a/ora/Models/History.swift b/ora/Models/History.swift index 3d6e0b2d..288bcce0 100644 --- a/ora/Models/History.swift +++ b/ora/Models/History.swift @@ -8,38 +8,28 @@ 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? - // Relationship to individual chronological visits - @Relationship var visits: [HistoryVisit] = [] - init( 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/HistoryVisit.swift b/ora/Models/HistoryVisit.swift deleted file mode 100644 index 907565dd..00000000 --- a/ora/Models/HistoryVisit.swift +++ /dev/null @@ -1,36 +0,0 @@ -import Foundation -import SwiftData - -// SwiftData model for individual chronological browsing history visits -@Model -final class HistoryVisit { - @Attribute(.unique) var id: UUID - var url: URL - var urlString: String - var title: String - var visitedAt: Date - var faviconURL: URL? - var faviconLocalFile: URL? - - // Relationship to consolidated history entry - @Relationship(inverse: \History.visits) var historyEntry: History? - - init( - id: UUID = UUID(), - url: URL, - title: String, - visitedAt: Date = Date(), - faviconURL: URL? = nil, - faviconLocalFile: URL? = nil, - historyEntry: History? = nil - ) { - self.id = id - self.url = url - self.urlString = url.absoluteString - self.title = title - self.visitedAt = visitedAt - self.faviconURL = faviconURL - self.faviconLocalFile = faviconLocalFile - self.historyEntry = historyEntry - } -} 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/Services/HistoryManager.swift b/ora/Services/HistoryManager.swift index cef9e28a..71d16114 100644 --- a/ora/Services/HistoryManager.swift +++ b/ora/Services/HistoryManager.swift @@ -23,54 +23,26 @@ class HistoryManager: ObservableObject { isPrivate: Bool = false ) { // Don't save history in private mode - guard !isPrivate else { return } - let urlString = url.absoluteString + guard !isPrivate else { + logger.debug("Skipping history recording - private mode") + return + } let now = Date() - // 1. Handle consolidated history (existing behavior) - let descriptor = FetchDescriptor( - predicate: #Predicate { history in - history.urlString == urlString - }, - sortBy: [.init(\.lastAccessedAt, order: .reverse)] - ) - - let consolidatedHistory: History - if let existing = try? modelContext.fetch(descriptor).first(where: { $0.container?.id == container.id }) { - existing.visitCount += 1 - existing.lastAccessedAt = now - consolidatedHistory = existing - } else { - let defaultFaviconURL = URL(string: "https://www.google.com/s2/favicons?domain=\(url.host ?? "google.com")") - let fallbackURL = URL(fileURLWithPath: "") - let resolvedFaviconURL = faviconURL ?? defaultFaviconURL ?? fallbackURL - consolidatedHistory = History( - url: url, - title: title, - faviconURL: resolvedFaviconURL, - faviconLocalFile: faviconLocalFile, - createdAt: now, - lastAccessedAt: now, - visitCount: 1, - container: container - ) - modelContext.insert(consolidatedHistory) - } + // 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 - // 2. Always create a new chronological visit entry - let visit = HistoryVisit( + let historyEntry = History( url: url, title: title, - visitedAt: now, - faviconURL: faviconURL, + faviconURL: resolvedFaviconURL, faviconLocalFile: faviconLocalFile, - historyEntry: consolidatedHistory + visitedAt: now, + container: container ) - modelContext.insert(visit) - - // 3. Add visit to the consolidated history's visits array - consolidatedHistory.visits.append(visit) + modelContext.insert(historyEntry) try? modelContext.save() } @@ -80,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 @@ -91,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 { @@ -112,31 +86,13 @@ class HistoryManager: ObservableObject { predicate: #Predicate { $0.container?.id == containerId } ) - // Fetch all visits and filter in-memory to avoid force unwraps - let visitDescriptor = FetchDescriptor() - do { let histories = try modelContext.fetch(descriptor) - let allVisits = try modelContext.fetch(visitDescriptor) - - // Filter visits safely without force unwraps - let visitsToDelete = allVisits.filter { visit in - guard let historyEntry = visit.historyEntry, - let visitContainer = historyEntry.container - else { - return false - } - return visitContainer.id == containerId - } for history in histories { modelContext.delete(history) } - for visit in visitsToDelete { - modelContext.delete(visit) - } - try modelContext.save() } catch { logger.error("Failed to clear history for container \(container.id): \(error.localizedDescription)") @@ -145,85 +101,57 @@ class HistoryManager: ObservableObject { // MARK: - Chronological History Methods - func getChronologicalHistory(for containerId: UUID, limit: Int? = nil) -> [HistoryVisit] { - // Fetch all visits first, then filter in-memory to avoid SwiftData predicate limitations - var descriptor = FetchDescriptor( - sortBy: [SortDescriptor(\.visitedAt, order: .reverse)] + func getChronologicalHistory(for containerId: UUID, limit: Int? = nil) -> [History] { + let containerDescriptor = FetchDescriptor( + predicate: #Predicate { $0.id == containerId } ) - if let limit { - descriptor.fetchLimit = limit * 3 // Fetch more to account for filtering - } - do { - let allVisits = try modelContext.fetch(descriptor) - let filteredVisits = allVisits.filter { visit in - guard let historyEntry = visit.historyEntry, - let container = historyEntry.container - else { - return false - } - return container.id == containerId - } + let containers = try modelContext.fetch(containerDescriptor) + if let container = containers.first { + // Sort the history by visitedAt in reverse order (most recent first) + let sortedHistory = container.history.sorted { $0.visitedAt > $1.visitedAt } - // Apply limit after filtering if specified - if let limit { - return Array(filteredVisits.prefix(limit)) + // Apply limit if specified + let results = limit != nil ? Array(sortedHistory.prefix(limit!)) : sortedHistory + + return results } - return filteredVisits } catch { - logger.error("Error fetching chronological history: \(error.localizedDescription)") - return [] + logger.error("Error fetching container: \(error.localizedDescription)") } + + return [] } - func searchChronologicalHistory(_ text: String, activeContainerId: UUID) -> [HistoryVisit] { + func searchChronologicalHistory(_ text: String, activeContainerId: UUID) -> [History] { let trimmedText = text.trimmingCharacters(in: .whitespaces) - // Fetch all visits first, then filter in-memory for better safety and flexibility - let descriptor = FetchDescriptor( - sortBy: [SortDescriptor(\.visitedAt, order: .reverse)] - ) + // Get all history for the container first + let allHistory = getChronologicalHistory(for: activeContainerId) - do { - let allVisits = try modelContext.fetch(descriptor) - let filteredVisits = allVisits.filter { visit in - // Defensive filtering for container match - guard let historyEntry = visit.historyEntry, - let container = historyEntry.container - else { - return false - } - - let containerMatches = container.id == activeContainerId - - // If no search text, just return container matches - if trimmedText.isEmpty { - return containerMatches - } - - // Check if text matches URL or title - let textMatches = visit.urlString.localizedStandardContains(trimmedText) || - visit.title.localizedStandardContains(trimmedText) - - return containerMatches && textMatches - } + // If no search text, return all + if trimmedText.isEmpty { + return allHistory + } - return filteredVisits - } catch { - logger.error("Error searching chronological history: \(error.localizedDescription)") - return [] + // Filter in memory for search text + let filteredHistory = allHistory.filter { history in + history.urlString.localizedStandardContains(trimmedText) || + history.title.localizedStandardContains(trimmedText) } + + return filteredHistory } - func deleteHistoryVisit(_ visit: HistoryVisit) { - modelContext.delete(visit) + func deleteHistory(_ history: History) { + modelContext.delete(history) try? modelContext.save() } - func deleteHistoryVisits(_ visits: [HistoryVisit]) { - for visit in visits { - modelContext.delete(visit) + func deleteHistories(_ histories: [History]) { + for history in histories { + modelContext.delete(history) } try? modelContext.save() } diff --git a/ora/UI/HistoryView.swift b/ora/UI/HistoryView.swift index 5139d38a..70b46dcc 100644 --- a/ora/UI/HistoryView.swift +++ b/ora/UI/HistoryView.swift @@ -13,7 +13,7 @@ struct HistoryView: View { @State private var showClearAllAlert = false @State private var isHeaderHovered = false - private var filteredVisits: [HistoryVisit] { + private var filteredVisits: [History] { guard let containerId = tabManager.activeContainer?.id else { return [] } if searchText.trimmingCharacters(in: .whitespaces).isEmpty { @@ -24,7 +24,7 @@ struct HistoryView: View { } // Group visits by date - private var groupedVisits: [(String, [HistoryVisit])] { + private var groupedVisits: [(String, [History])] { let calendar = Calendar.current let today = Date() @@ -258,7 +258,7 @@ struct HistoryView: View { } } - private func openHistoryItem(_ visit: HistoryVisit) { + private func openHistoryItem(_ visit: History) { guard !isSelectMode else { return } tabManager.openTab( @@ -270,7 +270,7 @@ struct HistoryView: View { private func deleteSelectedVisits() { let visitsToDelete = filteredVisits.filter { selectedVisits.contains($0.id) } - historyManager.deleteHistoryVisits(visitsToDelete) + historyManager.deleteHistories(visitsToDelete) selectedVisits.removeAll() isSelectMode = false } @@ -286,8 +286,8 @@ struct HistoryView: View { } } - private func deleteSingleVisit(_ visit: HistoryVisit) { - historyManager.deleteHistoryVisit(visit) + private func deleteSingleVisit(_ visit: History) { + historyManager.deleteHistory(visit) } } @@ -295,12 +295,12 @@ struct HistoryDateSection: View { @Environment(\.theme) private var theme let date: String - let visits: [HistoryVisit] + let visits: [History] let searchText: String let isSelectMode: Bool @Binding var selectedVisits: Set - let onVisitTap: (HistoryVisit) -> Void - let onVisitDelete: (HistoryVisit) -> Void + let onVisitTap: (History) -> Void + let onVisitDelete: (History) -> Void var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -318,7 +318,7 @@ struct HistoryDateSection: View { // Visit items ForEach(visits, id: \.id) { visit in - HistoryVisitRow( + HistoryRow( visit: visit, searchText: searchText, isSelectMode: isSelectMode, @@ -344,10 +344,10 @@ struct HistoryDateSection: View { } } -struct HistoryVisitRow: View { +struct HistoryRow: View { @Environment(\.theme) private var theme - let visit: HistoryVisit + let visit: History let searchText: String let isSelectMode: Bool let isSelected: Bool @@ -446,7 +446,7 @@ struct HistoryVisitRow: View { #Preview { do { - let historyContainer = try ModelContainer(for: History.self, HistoryVisit.self) + let historyContainer = try ModelContainer(for: History.self) let tabContainer = try ModelContainer(for: Tab.self) return HistoryView() From 4fbd60884a97cf16a072faaf61ebc6cbbe19c16b Mon Sep 17 00:00:00 2001 From: Chase Roossin Date: Sun, 5 Oct 2025 06:56:10 -0700 Subject: [PATCH 09/10] fix: make visitedAt optional for db migration --- ora/Models/History.swift | 4 ++-- ora/Services/HistoryManager.swift | 7 ++++++- ora/UI/HistoryView.swift | 15 ++++++++++----- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/ora/Models/History.swift b/ora/Models/History.swift index 288bcce0..32cd8a25 100644 --- a/ora/Models/History.swift +++ b/ora/Models/History.swift @@ -10,7 +10,7 @@ final class History { var title: String var faviconURL: URL? var faviconLocalFile: URL? - var visitedAt: Date // When this specific visit occurred + var visitedAt: Date? // When this specific visit occurred @Relationship(inverse: \TabContainer.history) var container: TabContainer? @@ -20,7 +20,7 @@ final class History { title: String, faviconURL: URL? = nil, faviconLocalFile: URL? = nil, - visitedAt: Date = Date(), + visitedAt: Date? = Date(), container: TabContainer? = nil ) { self.id = id diff --git a/ora/Services/HistoryManager.swift b/ora/Services/HistoryManager.swift index 71d16114..3e6dbeb0 100644 --- a/ora/Services/HistoryManager.swift +++ b/ora/Services/HistoryManager.swift @@ -110,7 +110,12 @@ class HistoryManager: ObservableObject { let containers = try modelContext.fetch(containerDescriptor) if let container = containers.first { // Sort the history by visitedAt in reverse order (most recent first) - let sortedHistory = container.history.sorted { $0.visitedAt > $1.visitedAt } + // Use a fallback date for any nil visitedAt values (from migrated records) + let sortedHistory = container.history.sorted { + let date1 = $0.visitedAt ?? Date.distantPast + let date2 = $1.visitedAt ?? Date.distantPast + return date1 > date2 + } // Apply limit if specified let results = limit != nil ? Array(sortedHistory.prefix(limit!)) : sortedHistory diff --git a/ora/UI/HistoryView.swift b/ora/UI/HistoryView.swift index 70b46dcc..6643f46b 100644 --- a/ora/UI/HistoryView.swift +++ b/ora/UI/HistoryView.swift @@ -29,10 +29,11 @@ struct HistoryView: View { let today = Date() let grouped = Dictionary(grouping: filteredVisits) { visit in - if calendar.isDate(visit.visitedAt, inSameDayAs: today) { + let visitDate = visit.visitedAt ?? Date.distantPast + if calendar.isDate(visitDate, inSameDayAs: today) { return "Today" } else if calendar.isDate( - visit.visitedAt, + visitDate, inSameDayAs: calendar.date(byAdding: .day, value: -1, to: today) ?? today ) { return "Yesterday" @@ -40,7 +41,7 @@ struct HistoryView: View { let formatter = DateFormatter() formatter.dateStyle = .full formatter.timeStyle = .none - return formatter.string(from: visit.visitedAt) + return formatter.string(from: visitDate) } } @@ -62,7 +63,11 @@ struct HistoryView: View { return sortedKeys.compactMap { key in guard let visits = grouped[key] else { return nil } - return (key, visits.sorted { $0.visitedAt > $1.visitedAt }) + return (key, visits.sorted { + let date1 = $0.visitedAt ?? Date.distantPast + let date2 = $1.visitedAt ?? Date.distantPast + return date1 > date2 + }) } } @@ -405,7 +410,7 @@ struct HistoryRow: View { Spacer() - Text(timeFormatter.string(from: visit.visitedAt)) + Text(timeFormatter.string(from: visit.visitedAt ?? Date.distantPast)) .font(.system(size: 12)) .foregroundColor(.secondary) } From c0fee78c25376dbf6356fec0721010e4ff05e1a8 Mon Sep 17 00:00:00 2001 From: Chase Roossin Date: Sun, 5 Oct 2025 07:02:30 -0700 Subject: [PATCH 10/10] fix: db query enhancements --- ora/Services/HistoryManager.swift | 20 ++++++++++++-------- ora/UI/HistoryView.swift | 7 +++---- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/ora/Services/HistoryManager.swift b/ora/Services/HistoryManager.swift index 3e6dbeb0..84de1acb 100644 --- a/ora/Services/HistoryManager.swift +++ b/ora/Services/HistoryManager.swift @@ -109,16 +109,20 @@ class HistoryManager: ObservableObject { do { let containers = try modelContext.fetch(containerDescriptor) if let container = containers.first { - // Sort the history by visitedAt in reverse order (most recent first) - // Use a fallback date for any nil visitedAt values (from migrated records) - let sortedHistory = container.history.sorted { - let date1 = $0.visitedAt ?? Date.distantPast - let date2 = $1.visitedAt ?? Date.distantPast - return date1 > date2 - } + // 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 = limit != nil ? Array(sortedHistory.prefix(limit!)) : sortedHistory + let results = if let limit { + Array(sortedHistory.prefix(limit)) + } else { + sortedHistory + } return results } diff --git a/ora/UI/HistoryView.swift b/ora/UI/HistoryView.swift index 6643f46b..36e716f6 100644 --- a/ora/UI/HistoryView.swift +++ b/ora/UI/HistoryView.swift @@ -29,7 +29,7 @@ struct HistoryView: View { let today = Date() let grouped = Dictionary(grouping: filteredVisits) { visit in - let visitDate = visit.visitedAt ?? Date.distantPast + guard let visitDate = visit.visitedAt else { return "Unknown" } if calendar.isDate(visitDate, inSameDayAs: today) { return "Today" } else if calendar.isDate( @@ -64,8 +64,7 @@ struct HistoryView: View { return sortedKeys.compactMap { key in guard let visits = grouped[key] else { return nil } return (key, visits.sorted { - let date1 = $0.visitedAt ?? Date.distantPast - let date2 = $1.visitedAt ?? Date.distantPast + guard let date1 = $0.visitedAt, let date2 = $1.visitedAt else { return false } return date1 > date2 }) } @@ -410,7 +409,7 @@ struct HistoryRow: View { Spacer() - Text(timeFormatter.string(from: visit.visitedAt ?? Date.distantPast)) + Text(timeFormatter.string(from: visit.visitedAt ?? Date())) .font(.system(size: 12)) .foregroundColor(.secondary) }