SPB Git

spb/zyquo-atlas Public License

The AI-native macOS web browser — every surface, intelligent.

Swift 75.2% JavaScript 22% Shell 2% Makefile 0.9%
6.8 KB · 208 lines swift
Raw Blame History
1//2//  TabManager.swift3//  Zyquo Atlas4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  Owns the ordered set of tabs and the active selection for a window. Handles9//  open / close / select / reorder / pin, routes page-initiated new-tab requests,10//  records history + downloads via the injected services, suspends background11//  tabs, and saves/restores the session per profile.12//1314import Foundation15import WebKit16import Combine1718@MainActor19final class TabManager: ObservableObject {20    @Published private(set) var tabs: [Tab] = []21    @Published var activeTabID: Tab.ID?2223    /// The blank/new-tab sentinel — the customizable start page renders for it.24    static let homeURL = URL(string: "https://duckduckgo.com")!2526    let profile: Profile27    /// Set once by the window before the first tab opens (history recording).28    var history: HistoryService?29    let downloads: DownloadManager30    private let sessionURL: URL?31    private var saveTask: Task<Void, Never>?3233    init(profile: Profile = .defaultProfile,34         history: HistoryService? = nil) {35        self.profile = profile36        self.history = history37        self.downloads = DownloadManager()38        // Only persistent profiles restore/save a session.39        sessionURL = profile.isPrivate ? nil40            : PersistenceService.shared.rootDirectory41                .appendingPathComponent("session-\(profile.id.uuidString).json")42    }4344    var activeTab: Tab? {45        tabs.first { $0.id == activeTabID }46    }4748    // MARK: - Pinning4950    func togglePin(_ id: Tab.ID) {51        guard let tab = tabs.first(where: { $0.id == id }) else { return }52        tab.isPinned.toggle()53        // Keep pinned tabs sorted to the front, preserving relative order.54        tabs.sort { ($0.isPinned ? 0 : 1, 0) < ($1.isPinned ? 0 : 1, 1) }55        objectWillChange.send()56        saveSession()57    }5859    // MARK: - Tab switching by index (⌘1–9)6061    func selectTab(atIndex index: Int) {62        guard tabs.indices.contains(index) else { return }63        selectTab(tabs[index].id)64    }6566    func selectNext() {67        guard let i = tabs.firstIndex(where: { $0.id == activeTabID }) else { return }68        selectTab(tabs[(i + 1) % tabs.count].id)69    }7071    // MARK: - Opening7273    /// Creates a new tab. If `url` is nil the tab opens the home page. When74    /// `select` is true it becomes the active tab.75    @discardableResult76    func newTab(url: URL? = nil, select: Bool = true) -> Tab {77        let tab = makeTab(configuration: nil)78        insert(tab, select: select)79        tab.load(url ?? Self.homeURL)80        return tab81    }8283    /// Loads omnibox text in the active tab, creating one if none exists.84    func loadInActiveTab(_ text: String) {85        let tab = activeTab ?? newTab(url: nil, select: true)86        tab.loadOmnibox(text)87    }8889    // MARK: - Closing / selecting9091    func closeTab(_ id: Tab.ID) {92        guard let index = tabs.firstIndex(where: { $0.id == id }) else { return }93        let wasActive = (id == activeTabID)94        tabs.remove(at: index)95        if wasActive {96            // Prefer the tab that shifted into this slot, else the previous one.97            let next = tabs[safe: index] ?? tabs[safe: index - 1]98            activeTabID = next?.id99        }100        if tabs.isEmpty { newTab() }101        saveSession()102    }103104    func selectTab(_ id: Tab.ID) {105        activeTabID = id106        activeTab?.activate()107        suspendInactiveTabs()108        saveSession()109    }110111    func moveTab(from source: IndexSet, to destination: Int) {112        tabs.move(fromOffsets: source, toOffset: destination)113        saveSession()114    }115116    // MARK: - Suspension117118    /// Suspends every non-active tab to reclaim memory (called after a switch).119    private func suspendInactiveTabs() {120        for tab in tabs where tab.id != activeTabID {121            tab.suspend()122        }123    }124125    // MARK: - Tab factory + new-tab routing126127    private func makeTab(configuration: WKWebViewConfiguration?) -> Tab {128        let tab = Tab(profile: profile, configuration: configuration)129        tab.downloadManager = downloads130        // Record history on navigation (never for private profiles).131        if !profile.isPrivate, let history {132            tab.onDidFinishNavigation = { [weak history] url, title in133                history?.record(url: url, title: title)134            }135        }136        // Page-initiated new tab (target=_blank, window.open, ⌘-click): create a137        // sibling tab that reuses the page-supplied configuration and return its138        // web view so the page drives the load.139        tab.onCreateTab = { [weak self] config, request in140            guard let self else { return nil }141            let child = self.makeTab(configuration: config)142            self.insert(child, select: true)143            if let request { child.webView.load(request) }144            return child.webView145        }146        tab.onClose = { [weak self] in147            self?.closeTab(tab.id)148        }149        return tab150    }151152    private func insert(_ tab: Tab, select: Bool) {153        tabs.append(tab)154        if select || activeTabID == nil {155            activeTabID = tab.id156            suspendInactiveTabs()157        }158        saveSession()159    }160161    // MARK: - Session restore162163    /// Reopens the previous session's tabs, or a single home tab if none.164    func restoreSessionOrOpenHome() {165        guard tabs.isEmpty else { return }166        if let url = sessionURL, let data = try? Data(contentsOf: url),167           let saved = try? JSONDecoder().decode(SavedSession.self, from: data),168           !saved.tabs.isEmpty {169            for t in saved.tabs {170                let tab = makeTab(configuration: nil)171                tab.isPinned = t.pinned172                tabs.append(tab)173                if let u = URL(string: t.url) { tab.load(u) }174            }175            activeTabID = tabs[safe: saved.activeIndex]?.id ?? tabs.first?.id176            suspendInactiveTabs()177        } else {178            newTab()179        }180    }181182    func saveSession() {183        guard let sessionURL else { return }184        let snapshot = SavedSession(185            tabs: tabs.compactMap { t in186                t.url.map { SavedTab(url: $0.absoluteString, pinned: t.isPinned) }187            },188            activeIndex: tabs.firstIndex { $0.id == activeTabID } ?? 0)189        saveTask?.cancel()190        saveTask = Task.detached(priority: .utility) {191            if let data = try? JSONEncoder().encode(snapshot) {192                try? data.write(to: sessionURL, options: .atomic)193            }194        }195    }196197    private struct SavedSession: Codable { var tabs: [SavedTab]; var activeIndex: Int }198    private struct SavedTab: Codable { var url: String; var pinned: Bool }199}200201// MARK: - Safe indexing202203private extension Array {204    subscript(safe index: Int) -> Element? {205        indices.contains(index) ? self[index] : nil206    }207}208