spb/zyquo-atlas Public License
The AI-native macOS web browser — every surface, intelligent.
Swift 75.2%
JavaScript 22%
Shell 2%
Makefile 0.9%
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