Skip to content
269 changes: 136 additions & 133 deletions Sources/AppDelegate.swift

Large diffs are not rendered by default.

14 changes: 12 additions & 2 deletions Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -374,8 +374,18 @@ struct TitlebarLayerBackground: NSViewRepresentable {
}

final class SidebarState: ObservableObject {
@Published var isVisible: Bool
@Published var persistedWidth: CGFloat
@Published var isVisible: Bool {
didSet {
guard isVisible != oldValue else { return }
AppDelegate.requestSessionSnapshotDirty(reason: "sidebar.visibility")
}
}
@Published var persistedWidth: CGFloat {
didSet {
guard abs(persistedWidth - oldValue) > 0.5 else { return }
AppDelegate.requestSessionSnapshotDirty(reason: "sidebar.width")
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

init(isVisible: Bool = true, persistedWidth: CGFloat = CGFloat(SessionPersistencePolicy.defaultSidebarWidth)) {
self.isVisible = isVisible
Expand Down
88 changes: 83 additions & 5 deletions Sources/Panels/BrowserPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1883,7 +1883,12 @@ final class BrowserPanel: Panel, ObservableObject {
/// The workspace ID this panel belongs to
private(set) var workspaceId: UUID

@Published private(set) var profileID: UUID
@Published private(set) var profileID: UUID {
didSet {
guard oldValue != profileID else { return }
noteSessionPersistenceStateChanged(reason: "profile")
}
}
@Published private(set) var historyStore: BrowserHistoryStore

/// The underlying web view
Expand Down Expand Up @@ -2128,11 +2133,21 @@ final class BrowserPanel: Panel, ObservableObject {
"""

/// Published URL being displayed
@Published private(set) var currentURL: URL?
@Published private(set) var currentURL: URL? {
didSet {
guard oldValue?.absoluteString != currentURL?.absoluteString else { return }
noteSessionPersistenceHistoryChangedIfNeeded(reason: "url")
}
}

/// Whether the browser panel should render its WKWebView in the content area.
/// New browser tabs stay in an empty "new tab" state until first navigation.
@Published private(set) var shouldRenderWebView: Bool = false
@Published private(set) var shouldRenderWebView: Bool = false {
didSet {
guard oldValue != shouldRenderWebView else { return }
noteSessionPersistenceStateChanged(reason: "renderability")
}
}

/// True when the browser is showing the internal empty new-tab page (no WKWebView attached yet).
var isShowingNewTabPage: Bool {
Expand All @@ -2152,17 +2167,28 @@ final class BrowserPanel: Panel, ObservableObject {
@Published private(set) var isDownloading: Bool = false

/// Published can go back state
@Published private(set) var canGoBack: Bool = false
@Published private(set) var canGoBack: Bool = false {
didSet {
guard oldValue != canGoBack else { return }
noteSessionPersistenceHistoryChangedIfNeeded(reason: "historyAvailability.back")
}
}

/// Published can go forward state
@Published private(set) var canGoForward: Bool = false
@Published private(set) var canGoForward: Bool = false {
didSet {
guard oldValue != canGoForward else { return }
noteSessionPersistenceHistoryChangedIfNeeded(reason: "historyAvailability.forward")
}
}

private var nativeCanGoBack: Bool = false
private var nativeCanGoForward: Bool = false
private var usesRestoredSessionHistory: Bool = false
private var restoredBackHistoryStack: [URL] = []
private var restoredForwardHistoryStack: [URL] = []
private var restoredHistoryCurrentURL: URL?
private var lastSessionPersistenceHistorySignature: String?

/// Published estimated progress (0.0 - 1.0)
@Published private(set) var estimatedProgress: Double = 0.0
Expand Down Expand Up @@ -2292,6 +2318,7 @@ final class BrowserPanel: Panel, ObservableObject {
private var preferredAttachedDeveloperToolsWidth: CGFloat?
private var preferredAttachedDeveloperToolsWidthFraction: CGFloat?
private var browserThemeMode: BrowserThemeMode
var onSessionPersistenceStateChange: ((String) -> Void)?

var displayTitle: String {
if !pageTitle.isEmpty {
Expand Down Expand Up @@ -2586,6 +2613,7 @@ final class BrowserPanel: Panel, ObservableObject {
Task { @MainActor [weak self] in
guard let self, self.isCurrentWebView(webView, instanceID: boundWebViewInstanceID) else { return }
self.realignRestoredSessionHistoryToLiveCurrentIfPossible()
self.noteSessionPersistenceHistoryChangedIfNeeded(reason: "history.finish")
boundHistoryStore.recordVisit(url: webView.url, title: webView.title)
self.refreshFavicon(from: webView)
// Keep find-in-page open through load completion and refresh matches for the new DOM.
Expand Down Expand Up @@ -3005,6 +3033,7 @@ final class BrowserPanel: Panel, ObservableObject {
restoredForwardHistoryStack = Array(Self.sanitizedSessionHistoryURLs(newForward).reversed())
restoredHistoryCurrentURL = liveCurrent
refreshNavigationAvailability()
noteSessionPersistenceHistoryChangedIfNeeded(reason: "history.realign")
return
}

Expand All @@ -3020,6 +3049,7 @@ final class BrowserPanel: Panel, ObservableObject {
restoredForwardHistoryStack = Array(Self.sanitizedSessionHistoryURLs(newForward).reversed())
restoredHistoryCurrentURL = liveCurrent
refreshNavigationAvailability()
noteSessionPersistenceHistoryChangedIfNeeded(reason: "history.realign")
return
}

Expand All @@ -3032,6 +3062,7 @@ final class BrowserPanel: Panel, ObservableObject {
#endif
restoredForwardHistoryStack.removeAll(keepingCapacity: false)
refreshNavigationAvailability()
noteSessionPersistenceHistoryChangedIfNeeded(reason: "history.realign")
}

func restoreSessionNavigationHistory(
Expand All @@ -3050,6 +3081,7 @@ final class BrowserPanel: Panel, ObservableObject {
restoredForwardHistoryStack = Array(restoredForward.reversed())
restoredHistoryCurrentURL = restoredCurrent
refreshNavigationAvailability()
noteSessionPersistenceHistoryChangedIfNeeded(reason: "history.restore")
}

func restoreSessionSnapshot(_ snapshot: SessionBrowserPanelSnapshot) {
Expand Down Expand Up @@ -3969,6 +4001,7 @@ final class BrowserPanel: Panel, ObservableObject {
}

deinit {
onSessionPersistenceStateChange = nil
developerToolsRestoreRetryWorkItem?.cancel()
developerToolsRestoreRetryWorkItem = nil
developerToolsTransitionSettleWorkItem?.cancel()
Expand Down Expand Up @@ -4311,6 +4344,7 @@ extension BrowserPanel {
private func setPreferredDeveloperToolsVisible(_ next: Bool) {
guard preferredDeveloperToolsVisible != next else { return }
preferredDeveloperToolsVisible = next
noteSessionPersistenceStateChanged(reason: "developerTools")
}

private func syncDeveloperToolsPresentationPreferenceFromUI() {
Expand Down Expand Up @@ -5489,6 +5523,7 @@ extension BrowserPanel {
restoredForwardHistoryStack.removeAll(keepingCapacity: false)
restoredHistoryCurrentURL = nil
refreshNavigationAvailability()
noteSessionPersistenceHistoryChangedIfNeeded(reason: "history.abandon")
}

private static func serializableSessionHistoryURLString(_ url: URL?) -> String? {
Expand Down Expand Up @@ -5623,16 +5658,59 @@ extension BrowserPanel {
#endif

private extension BrowserPanel {
func currentSessionPersistenceHistorySignature() -> String {
let nativeBack = webView.backForwardList.backList.compactMap {
Self.serializableSessionHistoryURLString($0.url)
}
let nativeForward = webView.backForwardList.forwardList.compactMap {
Self.serializableSessionHistoryURLString($0.url)
}

let backHistory: [String]
let forwardHistory: [String]
if usesRestoredSessionHistory {
let restoredBack = restoredBackHistoryStack.compactMap { Self.serializableSessionHistoryURLString($0) }
let restoredForward = restoredForwardHistoryStack.reversed().compactMap {
Self.serializableSessionHistoryURLString($0)
}
if isLiveSessionHistoryAlignedWithRestoredCurrent {
backHistory = restoredBack
forwardHistory = restoredForward.isEmpty ? nativeForward : restoredForward
} else {
backHistory = restoredBack + nativeBack
forwardHistory = nativeForward
}
} else {
backHistory = nativeBack
forwardHistory = nativeForward
}

let currentURLString = Self.serializableSessionHistoryURLString(resolvedCurrentSessionHistoryURL()) ?? ""
return "current=\(currentURLString)|back=\(backHistory)|forward=\(forwardHistory)"
}

func noteSessionPersistenceHistoryChangedIfNeeded(reason: String) {
let signature = currentSessionPersistenceHistorySignature()
guard signature != lastSessionPersistenceHistorySignature else { return }
lastSessionPersistenceHistorySignature = signature
noteSessionPersistenceStateChanged(reason: reason)
}

@discardableResult
func applyPageZoom(_ candidate: CGFloat) -> Bool {
let clamped = max(minPageZoom, min(maxPageZoom, candidate))
if abs(webView.pageZoom - clamped) < 0.0001 {
return false
}
webView.pageZoom = clamped
noteSessionPersistenceStateChanged(reason: "zoom")
return true
}

func noteSessionPersistenceStateChanged(reason: String) {
onSessionPersistenceStateChange?(reason)
}

static func responderChainContains(_ start: NSResponder?, target: NSResponder) -> Bool {
var r = start
var hops = 0
Expand Down
35 changes: 22 additions & 13 deletions Sources/SessionPersistence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ enum SessionPersistencePolicy {
static let maximumSidebarWidth: Double = 600
static let minimumWindowWidth: Double = 300
static let minimumWindowHeight: Double = 200
static let autosaveInterval: TimeInterval = 8.0
static let autosaveInterval: TimeInterval = 30.0
static let maxWindowsPerSnapshot: Int = 12
static let maxWorkspacesPerWindow: Int = 128
static let maxPanelsPerWorkspace: Int = 512
Expand Down Expand Up @@ -364,12 +364,14 @@ struct AppSessionSnapshot: Codable, Sendable {
enum SessionPersistenceStore {
static func load(fileURL: URL? = nil) -> AppSessionSnapshot? {
guard let fileURL = fileURL ?? defaultSnapshotFileURL() else { return nil }
guard let data = try? Data(contentsOf: fileURL) else { return nil }
let decoder = JSONDecoder()
guard let snapshot = try? decoder.decode(AppSessionSnapshot.self, from: data) else { return nil }
guard snapshot.version == SessionSnapshotSchema.currentVersion else { return nil }
guard !snapshot.windows.isEmpty else { return nil }
return snapshot
return autoreleasepool {
guard let data = try? Data(contentsOf: fileURL) else { return nil }
let decoder = JSONDecoder()
guard let snapshot = try? decoder.decode(AppSessionSnapshot.self, from: data) else { return nil }
guard snapshot.version == SessionSnapshotSchema.currentVersion else { return nil }
guard !snapshot.windows.isEmpty else { return nil }
return snapshot
}
}

@discardableResult
Expand All @@ -378,15 +380,22 @@ enum SessionPersistenceStore {
let directory = fileURL.deletingLastPathComponent()
do {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
let data = try encodedSnapshotData(snapshot)
if let existingData = try? Data(contentsOf: fileURL), existingData == data {
return true
}
try data.write(to: fileURL, options: .atomic)
return true
} catch {
return false
}

return autoreleasepool {
do {
let data = try encodedSnapshotData(snapshot)
if let existingData = try? Data(contentsOf: fileURL), existingData == data {
return true
}
try data.write(to: fileURL, options: .atomic)
return true
} catch {
return false
}
}
}

private static func encodedSnapshotData(_ snapshot: AppSessionSnapshot) throws -> Data {
Expand Down
7 changes: 6 additions & 1 deletion Sources/SidebarSelectionState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import SwiftUI

@MainActor
final class SidebarSelectionState: ObservableObject {
@Published var selection: SidebarSelection
@Published var selection: SidebarSelection {
didSet {
guard selection != oldValue else { return }
AppDelegate.requestSessionSnapshotDirty(reason: "sidebar.selection")
}
}

init(selection: SidebarSelection = .tabs) {
self.selection = selection
Expand Down
57 changes: 14 additions & 43 deletions Sources/TabManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -864,7 +864,14 @@ class TabManager: ObservableObject {
/// Used to apply title updates to the correct window instead of NSApp.keyWindow.
weak var window: NSWindow?

@Published var tabs: [Workspace] = []
@Published var tabs: [Workspace] = [] {
didSet {
let oldWorkspaceIds = oldValue.map(\.id)
let newWorkspaceIds = tabs.map(\.id)
guard oldWorkspaceIds != newWorkspaceIds else { return }
markSessionSnapshotDirty(reason: "workspaces")
}
}
@Published private(set) var isWorkspaceCycleHot: Bool = false
@Published private(set) var pendingBackgroundWorkspaceLoadIds: Set<UUID> = []
@Published private(set) var debugPinnedWorkspaceLoadIds: Set<UUID> = []
Expand All @@ -883,6 +890,11 @@ class TabManager: ObservableObject {
private nonisolated static let workspacePullRequestTerminalStateSweepInterval: TimeInterval = 15 * 60
private nonisolated static let workspacePullRequestPollJitterFraction = 0.10
private nonisolated static let workspacePullRequestProbeTimeout: TimeInterval = 5.0

private func markSessionSnapshotDirty(reason: String) {
AppDelegate.requestSessionSnapshotDirty(reason: "tabManager.\(reason)")
}

@Published var selectedTabId: UUID? {
willSet {
#if DEBUG
Expand Down Expand Up @@ -913,6 +925,7 @@ class TabManager: ObservableObject {
}
didSet {
guard selectedTabId != oldValue else { return }
markSessionSnapshotDirty(reason: "selectedWorkspace")
sentryBreadcrumb("workspace.switch", data: [
"tabCount": tabs.count
])
Expand Down Expand Up @@ -6735,48 +6748,6 @@ class TabManager: ObservableObject {
}

extension TabManager {
func sessionAutosaveFingerprint() -> Int {
var hasher = Hasher()
hasher.combine(selectedTabId)
hasher.combine(tabs.count)

for workspace in tabs.prefix(SessionPersistencePolicy.maxWorkspacesPerWindow) {
hasher.combine(workspace.id)
hasher.combine(workspace.focusedPanelId)
hasher.combine(workspace.currentDirectory)
hasher.combine(workspace.customTitle ?? "")
hasher.combine(workspace.customDescription ?? "")
hasher.combine(workspace.customColor ?? "")
hasher.combine(workspace.isPinned)
hasher.combine(workspace.panels.count)
hasher.combine(workspace.statusEntries.count)
hasher.combine(workspace.metadataBlocks.count)
hasher.combine(workspace.logEntries.count)
hasher.combine(workspace.panelDirectories.count)
hasher.combine(workspace.panelTitles.count)
hasher.combine(workspace.panelPullRequests.count)
hasher.combine(workspace.panelGitBranches.count)
hasher.combine(workspace.surfaceListeningPorts.count)

if let progress = workspace.progress {
hasher.combine(Int((progress.value * 1000).rounded()))
hasher.combine(progress.label)
} else {
hasher.combine(-1)
}

if let gitBranch = workspace.gitBranch {
hasher.combine(gitBranch.branch)
hasher.combine(gitBranch.isDirty)
} else {
hasher.combine("")
hasher.combine(false)
}
}

return hasher.finalize()
}

func sessionSnapshot(includeScrollback: Bool) -> SessionTabManagerSnapshot {
let restorableTabs = tabs
.filter { !$0.isRemoteWorkspace }
Expand Down
Loading