// // TabManager.swift // Zyquo Atlas // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // Owns the ordered set of tabs and the active selection for a window. Handles // open / close / select / reorder / pin, routes page-initiated new-tab requests, // records history + downloads via the injected services, suspends background // tabs, and saves/restores the session per profile. // import Foundation import WebKit import Combine @MainActor final class TabManager: ObservableObject { @Published private(set) var tabs: [Tab] = [] @Published var activeTabID: Tab.ID? /// The blank/new-tab sentinel — the customizable start page renders for it. static let homeURL = URL(string: "https://duckduckgo.com")! let profile: Profile /// Set once by the window before the first tab opens (history recording). var history: HistoryService? let downloads: DownloadManager private let sessionURL: URL? private var saveTask: Task? init(profile: Profile = .defaultProfile, history: HistoryService? = nil) { self.profile = profile self.history = history self.downloads = DownloadManager() // Only persistent profiles restore/save a session. sessionURL = profile.isPrivate ? nil : PersistenceService.shared.rootDirectory .appendingPathComponent("session-\(profile.id.uuidString).json") } var activeTab: Tab? { tabs.first { $0.id == activeTabID } } // MARK: - Pinning func togglePin(_ id: Tab.ID) { guard let tab = tabs.first(where: { $0.id == id }) else { return } tab.isPinned.toggle() // Keep pinned tabs sorted to the front, preserving relative order. tabs.sort { ($0.isPinned ? 0 : 1, 0) < ($1.isPinned ? 0 : 1, 1) } objectWillChange.send() saveSession() } // MARK: - Tab switching by index (⌘1–9) func selectTab(atIndex index: Int) { guard tabs.indices.contains(index) else { return } selectTab(tabs[index].id) } func selectNext() { guard let i = tabs.firstIndex(where: { $0.id == activeTabID }) else { return } selectTab(tabs[(i + 1) % tabs.count].id) } // MARK: - Opening /// Creates a new tab. If `url` is nil the tab opens the home page. When /// `select` is true it becomes the active tab. @discardableResult func newTab(url: URL? = nil, select: Bool = true) -> Tab { let tab = makeTab(configuration: nil) insert(tab, select: select) tab.load(url ?? Self.homeURL) return tab } /// Loads omnibox text in the active tab, creating one if none exists. func loadInActiveTab(_ text: String) { let tab = activeTab ?? newTab(url: nil, select: true) tab.loadOmnibox(text) } // MARK: - Closing / selecting func closeTab(_ id: Tab.ID) { guard let index = tabs.firstIndex(where: { $0.id == id }) else { return } let wasActive = (id == activeTabID) tabs.remove(at: index) if wasActive { // Prefer the tab that shifted into this slot, else the previous one. let next = tabs[safe: index] ?? tabs[safe: index - 1] activeTabID = next?.id } if tabs.isEmpty { newTab() } saveSession() } func selectTab(_ id: Tab.ID) { activeTabID = id activeTab?.activate() suspendInactiveTabs() saveSession() } func moveTab(from source: IndexSet, to destination: Int) { tabs.move(fromOffsets: source, toOffset: destination) saveSession() } // MARK: - Suspension /// Suspends every non-active tab to reclaim memory (called after a switch). private func suspendInactiveTabs() { for tab in tabs where tab.id != activeTabID { tab.suspend() } } // MARK: - Tab factory + new-tab routing private func makeTab(configuration: WKWebViewConfiguration?) -> Tab { let tab = Tab(profile: profile, configuration: configuration) tab.downloadManager = downloads // Record history on navigation (never for private profiles). if !profile.isPrivate, let history { tab.onDidFinishNavigation = { [weak history] url, title in history?.record(url: url, title: title) } } // Page-initiated new tab (target=_blank, window.open, ⌘-click): create a // sibling tab that reuses the page-supplied configuration and return its // web view so the page drives the load. tab.onCreateTab = { [weak self] config, request in guard let self else { return nil } let child = self.makeTab(configuration: config) self.insert(child, select: true) if let request { child.webView.load(request) } return child.webView } tab.onClose = { [weak self] in self?.closeTab(tab.id) } return tab } private func insert(_ tab: Tab, select: Bool) { tabs.append(tab) if select || activeTabID == nil { activeTabID = tab.id suspendInactiveTabs() } saveSession() } // MARK: - Session restore /// Reopens the previous session's tabs, or a single home tab if none. func restoreSessionOrOpenHome() { guard tabs.isEmpty else { return } if let url = sessionURL, let data = try? Data(contentsOf: url), let saved = try? JSONDecoder().decode(SavedSession.self, from: data), !saved.tabs.isEmpty { for t in saved.tabs { let tab = makeTab(configuration: nil) tab.isPinned = t.pinned tabs.append(tab) if let u = URL(string: t.url) { tab.load(u) } } activeTabID = tabs[safe: saved.activeIndex]?.id ?? tabs.first?.id suspendInactiveTabs() } else { newTab() } } func saveSession() { guard let sessionURL else { return } let snapshot = SavedSession( tabs: tabs.compactMap { t in t.url.map { SavedTab(url: $0.absoluteString, pinned: t.isPinned) } }, activeIndex: tabs.firstIndex { $0.id == activeTabID } ?? 0) saveTask?.cancel() saveTask = Task.detached(priority: .utility) { if let data = try? JSONEncoder().encode(snapshot) { try? data.write(to: sessionURL, options: .atomic) } } } private struct SavedSession: Codable { var tabs: [SavedTab]; var activeIndex: Int } private struct SavedTab: Codable { var url: String; var pinned: Bool } } // MARK: - Safe indexing private extension Array { subscript(safe index: Int) -> Element? { indices.contains(index) ? self[index] : nil } }