diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 7e346a71f..bd0e0d763 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2218,12 +2218,87 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent Self.detectRunningUnderXCTest(env) } + private nonisolated static let sessionSnapshotDirtyRequestLock = NSLock() + private nonisolated(unsafe) static var sessionSnapshotDirtyRequestTaskScheduled = false + private nonisolated(unsafe) static var sessionSnapshotDirtyRequestQueuedDuringWrite = false + private nonisolated(unsafe) static var sessionSnapshotDirtyRequestMirrorIsDirty = false + private nonisolated(unsafe) static var sessionSnapshotDirtyRequestWriteInFlight = false + private nonisolated(unsafe) static var sessionSnapshotDirtyRequestLatestReason = "unspecified" +#if DEBUG + nonisolated(unsafe) static var sessionSnapshotDirtyRequestObserverForTesting: ((String) -> Void)? +#endif + + nonisolated static func requestSessionSnapshotDirty(reason: String) { +#if DEBUG + sessionSnapshotDirtyRequestObserverForTesting?(reason) +#endif + guard beginSessionSnapshotDirtyRequest(reason: reason) else { return } + Task { @MainActor in + let pendingReason = takeSessionSnapshotDirtyRequestReason() + _ = AppDelegate.shared?.markSessionSnapshotDirty(reason: pendingReason) + finishSessionSnapshotDirtyRequestDispatch() + } + } + + private nonisolated static func beginSessionSnapshotDirtyRequest(reason: String) -> Bool { + sessionSnapshotDirtyRequestLock.lock() + defer { sessionSnapshotDirtyRequestLock.unlock() } + + sessionSnapshotDirtyRequestLatestReason = reason + if sessionSnapshotDirtyRequestTaskScheduled { + return false + } + if sessionSnapshotDirtyRequestMirrorIsDirty { + guard sessionSnapshotDirtyRequestWriteInFlight, + !sessionSnapshotDirtyRequestQueuedDuringWrite else { + return false + } + sessionSnapshotDirtyRequestQueuedDuringWrite = true + } + sessionSnapshotDirtyRequestTaskScheduled = true + return true + } + + private nonisolated static func takeSessionSnapshotDirtyRequestReason() -> String { + sessionSnapshotDirtyRequestLock.lock() + defer { sessionSnapshotDirtyRequestLock.unlock() } + return sessionSnapshotDirtyRequestLatestReason + } + + private nonisolated static func finishSessionSnapshotDirtyRequestDispatch() { + sessionSnapshotDirtyRequestLock.lock() + defer { sessionSnapshotDirtyRequestLock.unlock() } + sessionSnapshotDirtyRequestTaskScheduled = false + } + + private nonisolated static func setSessionSnapshotDirtyRequestMirror(isDirty: Bool) { + sessionSnapshotDirtyRequestLock.lock() + defer { sessionSnapshotDirtyRequestLock.unlock() } + sessionSnapshotDirtyRequestMirrorIsDirty = isDirty + if !isDirty && !sessionSnapshotDirtyRequestWriteInFlight { + sessionSnapshotDirtyRequestQueuedDuringWrite = false + } + } + + private nonisolated static func setSessionSnapshotDirtyRequestWriteInFlight(_ isInFlight: Bool) { + sessionSnapshotDirtyRequestLock.lock() + defer { sessionSnapshotDirtyRequestLock.unlock() } + if isInFlight && !sessionSnapshotDirtyRequestWriteInFlight { + sessionSnapshotDirtyRequestQueuedDuringWrite = false + } + sessionSnapshotDirtyRequestWriteInFlight = isInFlight + if !isInFlight { + sessionSnapshotDirtyRequestQueuedDuringWrite = false + } + } + private final class MainWindowContext { let windowId: UUID let tabManager: TabManager let sidebarState: SidebarState let sidebarSelectionState: SidebarSelectionState weak var window: NSWindow? + var sessionSnapshotWindowObservers: [NSObjectProtocol] = [] init( windowId: UUID, @@ -2238,6 +2313,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent self.sidebarSelectionState = sidebarSelectionState self.window = window } + + deinit { + sessionSnapshotWindowObservers.forEach(NotificationCenter.default.removeObserver) + } } private final class MainWindowController: NSWindowController, NSWindowDelegate { @@ -2430,8 +2509,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var didAttemptStartupSessionRestore = false private var isApplyingStartupSessionRestore = false private var sessionAutosaveTimer: DispatchSourceTimer? - private var sessionAutosaveTickInFlight = false - private var sessionAutosaveDeferredRetryPending = false + private var sessionSnapshotIsDirty = false + private var sessionSnapshotDirtyGeneration: UInt64 = 0 + private var sessionSnapshotPendingWriteCount = 0 + private var sessionAutosaveScheduledDeadlineUptime: TimeInterval? private let sessionPersistenceQueue = DispatchQueue( label: "com.cmuxterm.app.sessionPersistence", qos: .utility @@ -2443,8 +2524,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private nonisolated static func enqueueLaunchServicesRegistrationWork(_ work: @escaping @Sendable () -> Void) { launchServicesRegistrationQueue.async(execute: work) } - private var lastSessionAutosaveFingerprint: Int? - private var lastSessionAutosavePersistedAt: Date = .distantPast private var lastTypingActivityAt: TimeInterval = 0 private var didHandleExplicitOpenIntentAtStartup = false private var isTerminatingApp = false @@ -3929,7 +4008,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func completeStartupSessionRestore() { startupSessionSnapshot = nil isApplyingStartupSessionRestore = false - _ = saveSessionSnapshot(includeScrollback: false) + markSessionSnapshotDirty(reason: "session.restore.completed") } private func applySessionWindowSnapshot( @@ -4317,14 +4396,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard !isRunningUnderXCTest(env) else { return } let timer = DispatchSource.makeTimerSource(queue: .main) - let interval = SessionPersistencePolicy.autosaveInterval - timer.schedule(deadline: .now() + interval, repeating: interval, leeway: .seconds(1)) + timer.schedule(deadline: .distantFuture, leeway: .milliseconds(250)) timer.setEventHandler { [weak self] in - guard let self, - Self.shouldRunSessionAutosaveTick(isTerminatingApp: self.isTerminatingApp) else { + guard let self else { + return + } + self.sessionAutosaveScheduledDeadlineUptime = nil + guard Self.shouldRunSessionAutosaveTick(isTerminatingApp: self.isTerminatingApp) else { return } - self.runSessionAutosaveTick(source: "timer") + self.runSessionAutosaveTick(source: "debouncedTimer") } sessionAutosaveTimer = timer timer.resume() @@ -4333,8 +4414,60 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func stopSessionAutosaveTimer() { sessionAutosaveTimer?.cancel() sessionAutosaveTimer = nil - sessionAutosaveTickInFlight = false - sessionAutosaveDeferredRetryPending = false + sessionAutosaveScheduledDeadlineUptime = nil + } + + @discardableResult + private func markSessionSnapshotDirty(reason: String) -> Bool { + guard !isApplyingStartupSessionRestore else { return false } + sessionSnapshotDirtyGeneration &+= 1 + sessionSnapshotIsDirty = true + Self.setSessionSnapshotDirtyRequestMirror(isDirty: true) + guard Self.shouldRunSessionAutosaveTick(isTerminatingApp: isTerminatingApp) else { return true } + scheduleSessionAutosave( + after: SessionPersistencePolicy.autosaveInterval, + source: reason, + allowDelayExtension: false + ) + return true + } + + private func scheduleSessionAutosave( + after delay: TimeInterval, + source: String, + allowDelayExtension: Bool = false + ) { + guard sessionSnapshotIsDirty else { return } + guard delay.isFinite else { return } + startSessionAutosaveTimerIfNeeded() + guard let sessionAutosaveTimer else { return } + + let clampedDelay = max(0, delay) + let targetDeadlineUptime = ProcessInfo.processInfo.systemUptime + clampedDelay + if Self.shouldKeepExistingSessionAutosaveSchedule( + allowDelayExtension: allowDelayExtension, + existingDeadlineUptime: sessionAutosaveScheduledDeadlineUptime, + targetDeadlineUptime: targetDeadlineUptime + ) { +#if DEBUG + dlog( + "session.save.schedule.kept source=\(source) includeScrollback=0 " + + "existingDelayMs=\(Int(max(0, ((sessionAutosaveScheduledDeadlineUptime ?? targetDeadlineUptime) - ProcessInfo.processInfo.systemUptime) * 1000).rounded()))" + ) +#endif + return + } + sessionAutosaveScheduledDeadlineUptime = targetDeadlineUptime + sessionAutosaveTimer.schedule( + deadline: .now() + clampedDelay, + leeway: .milliseconds(250) + ) + +#if DEBUG + dlog( + "session.save.scheduled source=\(source) includeScrollback=0 delayMs=\(Int((clampedDelay * 1000).rounded()))" + ) +#endif } private func installLifecycleSnapshotObserversIfNeeded() { @@ -4417,40 +4550,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent didDisableSuddenTermination = false } - private func sessionAutosaveFingerprint(includeScrollback: Bool) -> Int? { - guard !includeScrollback else { return nil } - - var hasher = Hasher() - let contexts = mainWindowContexts.values.sorted { lhs, rhs in - lhs.windowId.uuidString < rhs.windowId.uuidString - } - hasher.combine(contexts.count) - - for context in contexts.prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) { - hasher.combine(context.windowId) - hasher.combine(context.tabManager.sessionAutosaveFingerprint()) - hasher.combine(context.sidebarState.isVisible) - hasher.combine( - Int(SessionPersistencePolicy.sanitizedSidebarWidth(Double(context.sidebarState.persistedWidth)).rounded()) - ) - - switch context.sidebarSelectionState.selection { - case .tabs: - hasher.combine(0) - case .notifications: - hasher.combine(1) - } - - if let window = context.window ?? windowForMainWindowId(context.windowId) { - Self.hashFrame(window.frame, into: &hasher) - } else { - hasher.combine(-1) - } - } - - return hasher.finalize() - } - @discardableResult private func saveSessionSnapshot(includeScrollback: Bool, removeWhenEmpty: Bool = false) -> Bool { if Self.shouldSkipSessionSaveDuringStartupRestore( @@ -4467,6 +4566,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent isTerminatingApp: isTerminatingApp, includeScrollback: includeScrollback ) + let startedDirtyGeneration = sessionSnapshotDirtyGeneration #if DEBUG let timingStart = CmuxTypingTiming.start() defer { @@ -4479,12 +4579,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif guard let snapshot = buildSessionSnapshot(includeScrollback: includeScrollback) else { + if !writeSynchronously { + sessionSnapshotPendingWriteCount += 1 + Self.setSessionSnapshotDirtyRequestWriteInFlight(sessionSnapshotPendingWriteCount > 0) + } persistSessionSnapshot( nil, removeWhenEmpty: removeWhenEmpty, persistedGeometryData: nil, synchronously: writeSynchronously - ) + ) { [weak self] in + self?.completeSessionSnapshotPersistence( + startedDirtyGeneration: startedDirtyGeneration, + wroteAsynchronously: !writeSynchronously, + source: "empty" + ) + } return false } @@ -4498,12 +4608,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #if DEBUG debugLogSessionSaveSnapshot(snapshot, includeScrollback: includeScrollback) #endif + if !writeSynchronously { + sessionSnapshotPendingWriteCount += 1 + Self.setSessionSnapshotDirtyRequestWriteInFlight(sessionSnapshotPendingWriteCount > 0) + } persistSessionSnapshot( snapshot, removeWhenEmpty: false, persistedGeometryData: persistedGeometryData, synchronously: writeSynchronously - ) + ) { [weak self] in + self?.completeSessionSnapshotPersistence( + startedDirtyGeneration: startedDirtyGeneration, + wroteAsynchronously: !writeSynchronously, + source: includeScrollback ? "snapshot.scrollback" : "snapshot" + ) + } return true } @@ -4528,6 +4648,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent !isTerminatingApp } + nonisolated static func shouldKeepExistingSessionAutosaveSchedule( + allowDelayExtension: Bool, + existingDeadlineUptime: TimeInterval?, + targetDeadlineUptime: TimeInterval + ) -> Bool { + guard !allowDelayExtension, let existingDeadlineUptime else { return false } + return targetDeadlineUptime >= existingDeadlineUptime + } + private func remainingSessionAutosaveTypingQuietPeriod( nowUptime: TimeInterval = ProcessInfo.processInfo.systemUptime ) -> TimeInterval? { @@ -4537,22 +4666,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return Self.sessionAutosaveTypingQuietPeriod - elapsed } - private func scheduleDeferredSessionAutosaveRetry(after delay: TimeInterval) { - guard delay.isFinite, delay > 0 else { return } - guard !sessionAutosaveDeferredRetryPending else { return } - sessionAutosaveDeferredRetryPending = true - sessionPersistenceQueue.asyncAfter(deadline: .now() + delay) { [weak self] in - Task { @MainActor [weak self] in - guard let self else { return } - self.sessionAutosaveDeferredRetryPending = false - self.runSessionAutosaveTick(source: "typingQuietRetry") - } - } - } - private func runSessionAutosaveTick(source: String) { guard Self.shouldRunSessionAutosaveTick(isTerminatingApp: isTerminatingApp) else { return } - guard !sessionAutosaveTickInFlight else { return } + guard sessionSnapshotIsDirty else { +#if DEBUG + dlog("session.save.skipped reason=clean includeScrollback=0 source=\(source)") +#endif + return + } + guard sessionSnapshotPendingWriteCount == 0 else { + scheduleSessionAutosave(after: 1, source: "pendingWriteRetry") + return + } if let remainingQuietPeriod = remainingSessionAutosaveTypingQuietPeriod() { #if DEBUG dlog( @@ -4560,25 +4685,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "retryMs=\(Int((remainingQuietPeriod * 1000).rounded()))" ) #endif - scheduleDeferredSessionAutosaveRetry(after: remainingQuietPeriod) + scheduleSessionAutosave(after: remainingQuietPeriod, source: "typingQuietRetry") return } - sessionAutosaveTickInFlight = true #if DEBUG let timingStart = CmuxTypingTiming.start() let phaseStart = ProcessInfo.processInfo.systemUptime - var fingerprintMs: Double = 0 var saveMs: Double = 0 defer { - sessionAutosaveTickInFlight = false let totalMs = (ProcessInfo.processInfo.systemUptime - phaseStart) * 1000.0 CmuxTypingTiming.logBreakdown( path: "session.autosaveTick.phase", totalMs: totalMs, thresholdMs: 2.0, parts: [ - ("fingerprintMs", fingerprintMs), ("saveMs", saveMs), ], extra: "source=\(source)" @@ -4589,34 +4710,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent extra: "source=\(source)" ) } -#else - defer { sessionAutosaveTickInFlight = false } #endif - let now = Date() -#if DEBUG - let fingerprintStart = ProcessInfo.processInfo.systemUptime -#endif - let autosaveFingerprint = sessionAutosaveFingerprint(includeScrollback: false) -#if DEBUG - fingerprintMs = (ProcessInfo.processInfo.systemUptime - fingerprintStart) * 1000.0 -#endif - if Self.shouldSkipSessionAutosaveForUnchangedFingerprint( - isTerminatingApp: isTerminatingApp, - includeScrollback: false, - previousFingerprint: lastSessionAutosaveFingerprint, - currentFingerprint: autosaveFingerprint, - lastPersistedAt: lastSessionAutosavePersistedAt, - now: now - ) { -#if DEBUG - dlog( - "session.save.skipped reason=unchanged_autosave_fingerprint includeScrollback=0 source=\(source)" - ) -#endif - return - } - #if DEBUG let saveStart = ProcessInfo.processInfo.systemUptime #endif @@ -4624,11 +4719,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #if DEBUG saveMs = (ProcessInfo.processInfo.systemUptime - saveStart) * 1000.0 #endif - updateSessionAutosaveSaveState( - includeScrollback: false, - persistedAt: now, - fingerprint: autosaveFingerprint - ) } fileprivate func recordTypingActivity() { @@ -4642,54 +4732,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent isTerminatingApp && includeScrollback } - nonisolated static func shouldSkipSessionAutosaveForUnchangedFingerprint( - isTerminatingApp: Bool, - includeScrollback: Bool, - previousFingerprint: Int?, - currentFingerprint: Int?, - lastPersistedAt: Date, - now: Date, - maximumAutosaveSkippableInterval: TimeInterval = 60 - ) -> Bool { - guard !isTerminatingApp, - !includeScrollback, - let previousFingerprint, - let currentFingerprint, - previousFingerprint == currentFingerprint else { - return false - } - - return now.timeIntervalSince(lastPersistedAt) < maximumAutosaveSkippableInterval - } - - private func updateSessionAutosaveSaveState( - includeScrollback: Bool, - persistedAt: Date, - fingerprint: Int? - ) { - guard !isTerminatingApp, !includeScrollback else { return } - lastSessionAutosaveFingerprint = fingerprint - lastSessionAutosavePersistedAt = persistedAt - } - - private nonisolated static func hashFrame(_ frame: NSRect, into hasher: inout Hasher) { - let standardized = frame.standardized - let quantized = [ - standardized.origin.x, - standardized.origin.y, - standardized.size.width, - standardized.size.height, - ].map { Int(($0 * 2).rounded()) } - quantized.forEach { hasher.combine($0) } - } - private func persistSessionSnapshot( _ snapshot: AppSessionSnapshot?, removeWhenEmpty: Bool, persistedGeometryData: Data?, - synchronously: Bool + synchronously: Bool, + completion: (() -> Void)? = nil ) { - guard snapshot != nil || removeWhenEmpty || persistedGeometryData != nil else { return } + guard snapshot != nil || removeWhenEmpty || persistedGeometryData != nil else { + completion?() + return + } let writeBlock = { Self.removeLegacyPersistedWindowGeometry() @@ -4707,9 +4760,57 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } if synchronously { - writeBlock() + sessionPersistenceQueue.sync(execute: writeBlock) + completion?() } else { - sessionPersistenceQueue.async(execute: writeBlock) + sessionPersistenceQueue.async { + writeBlock() + if let completion { + Task { @MainActor in + completion() + } + } + } + } + } + + private func completeSessionSnapshotPersistence( + startedDirtyGeneration: UInt64, + wroteAsynchronously: Bool, + source: String + ) { + if wroteAsynchronously { + sessionSnapshotPendingWriteCount = max(0, sessionSnapshotPendingWriteCount - 1) + Self.setSessionSnapshotDirtyRequestWriteInFlight(sessionSnapshotPendingWriteCount > 0) + } + + let dirtiedDuringWrite = sessionSnapshotDirtyGeneration != startedDirtyGeneration +#if DEBUG + dlog( + "session.save.completed source=\(source) async=\(wroteAsynchronously ? 1 : 0) " + + "dirtyDuringWrite=\(dirtiedDuringWrite ? 1 : 0) pendingWrites=\(sessionSnapshotPendingWriteCount)" + ) +#endif + if dirtiedDuringWrite { + sessionSnapshotIsDirty = true + Self.setSessionSnapshotDirtyRequestMirror(isDirty: true) + guard sessionSnapshotPendingWriteCount == 0, + Self.shouldRunSessionAutosaveTick(isTerminatingApp: isTerminatingApp) else { + return + } + if sessionAutosaveScheduledDeadlineUptime == nil { + scheduleSessionAutosave( + after: SessionPersistencePolicy.autosaveInterval, + source: "persistenceCompletionRetry" + ) + } + return + } + + if sessionSnapshotPendingWriteCount == 0 { + sessionSnapshotIsDirty = false + sessionAutosaveScheduledDeadlineUptime = nil + Self.setSessionSnapshotDirtyRequestMirror(isDirty: false) } } @@ -4803,6 +4904,32 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent NotificationCenter.default.post(name: .mainWindowContextsDidChange, object: self) } + private func installSessionSnapshotWindowObservers(for context: MainWindowContext, window: NSWindow) { + context.sessionSnapshotWindowObservers.forEach(NotificationCenter.default.removeObserver) + context.sessionSnapshotWindowObservers.removeAll() + + let center = NotificationCenter.default + let observe: (Notification.Name, String) -> Void = { [weak self, weak window] name, reason in + guard let self else { return } + let observer = center.addObserver( + forName: name, + object: window, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.markSessionSnapshotDirty(reason: reason) + } + } + context.sessionSnapshotWindowObservers.append(observer) + } + + observe(NSWindow.didBecomeKeyNotification, "window.key") + observe(NSWindow.didMoveNotification, "window.move") + observe(NSWindow.didResizeNotification, "window.resize") + observe(NSWindow.didChangeScreenNotification, "window.screen") + observe(NSWindow.didChangeBackingPropertiesNotification, "window.backing") + } + /// Register a terminal window with the AppDelegate so menu commands and socket control /// can target whichever window is currently active. func registerMainWindow( @@ -4818,19 +4945,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #if DEBUG let priorManagerToken = debugManagerToken(self.tabManager) #endif + let context: MainWindowContext if let existing = mainWindowContexts[key] { existing.window = window + context = existing } else if let existing = mainWindowContexts.values.first(where: { $0.windowId == windowId }) { existing.window = window reindexMainWindowContextIfNeeded(existing, for: window) + context = existing } else { - mainWindowContexts[key] = MainWindowContext( + let created = MainWindowContext( windowId: windowId, tabManager: tabManager, sidebarState: sidebarState, sidebarSelectionState: sidebarSelectionState, window: window ) + mainWindowContexts[key] = created + context = created NotificationCenter.default.addObserver( forName: NSWindow.willCloseNotification, object: window, @@ -4840,6 +4972,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent self.unregisterMainWindow(closing) } } + installSessionSnapshotWindowObservers(for: context, window: window) commandPaletteVisibilityByWindowId[windowId] = false commandPaletteSelectionByWindowId[windowId] = 0 commandPaletteSnapshotByWindowId[windowId] = .empty @@ -4856,7 +4989,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent attemptStartupSessionRestoreIfNeeded(primaryWindow: window) if !isTerminatingApp { - _ = saveSessionSnapshot(includeScrollback: false) + if !saveSessionSnapshot(includeScrollback: false) { + markSessionSnapshotDirty(reason: "mainWindow.register") + } } } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index d90e52592..2e8ac95f6 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -374,13 +374,27 @@ 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 { + let sanitizedWidth = CGFloat(SessionPersistencePolicy.sanitizedSidebarWidth(Double(persistedWidth))) + guard abs(sanitizedWidth - lastSessionSnapshotDirtyWidth) > 0.5 else { return } + lastSessionSnapshotDirtyWidth = sanitizedWidth + AppDelegate.requestSessionSnapshotDirty(reason: "sidebar.width") + } + } + private var lastSessionSnapshotDirtyWidth: CGFloat init(isVisible: Bool = true, persistedWidth: CGFloat = CGFloat(SessionPersistencePolicy.defaultSidebarWidth)) { self.isVisible = isVisible let sanitized = SessionPersistencePolicy.sanitizedSidebarWidth(Double(persistedWidth)) self.persistedWidth = CGFloat(sanitized) + self.lastSessionSnapshotDirtyWidth = CGFloat(sanitized) } func toggle() { diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 354036c2d..2fa370e05 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -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 @@ -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 { @@ -2152,10 +2167,20 @@ 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 @@ -2163,6 +2188,7 @@ final class BrowserPanel: Panel, ObservableObject { 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 @@ -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 { @@ -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. @@ -3005,6 +3033,7 @@ final class BrowserPanel: Panel, ObservableObject { restoredForwardHistoryStack = Array(Self.sanitizedSessionHistoryURLs(newForward).reversed()) restoredHistoryCurrentURL = liveCurrent refreshNavigationAvailability() + noteSessionPersistenceHistoryChangedIfNeeded(reason: "history.realign") return } @@ -3020,6 +3049,7 @@ final class BrowserPanel: Panel, ObservableObject { restoredForwardHistoryStack = Array(Self.sanitizedSessionHistoryURLs(newForward).reversed()) restoredHistoryCurrentURL = liveCurrent refreshNavigationAvailability() + noteSessionPersistenceHistoryChangedIfNeeded(reason: "history.realign") return } @@ -3032,6 +3062,7 @@ final class BrowserPanel: Panel, ObservableObject { #endif restoredForwardHistoryStack.removeAll(keepingCapacity: false) refreshNavigationAvailability() + noteSessionPersistenceHistoryChangedIfNeeded(reason: "history.realign") } func restoreSessionNavigationHistory( @@ -3050,6 +3081,7 @@ final class BrowserPanel: Panel, ObservableObject { restoredForwardHistoryStack = Array(restoredForward.reversed()) restoredHistoryCurrentURL = restoredCurrent refreshNavigationAvailability() + noteSessionPersistenceHistoryChangedIfNeeded(reason: "history.restore") } func restoreSessionSnapshot(_ snapshot: SessionBrowserPanelSnapshot) { @@ -3969,6 +4001,7 @@ final class BrowserPanel: Panel, ObservableObject { } deinit { + onSessionPersistenceStateChange = nil developerToolsRestoreRetryWorkItem?.cancel() developerToolsRestoreRetryWorkItem = nil developerToolsTransitionSettleWorkItem?.cancel() @@ -4311,6 +4344,7 @@ extension BrowserPanel { private func setPreferredDeveloperToolsVisible(_ next: Bool) { guard preferredDeveloperToolsVisible != next else { return } preferredDeveloperToolsVisible = next + noteSessionPersistenceStateChanged(reason: "developerTools") } private func syncDeveloperToolsPresentationPreferenceFromUI() { @@ -5489,6 +5523,7 @@ extension BrowserPanel { restoredForwardHistoryStack.removeAll(keepingCapacity: false) restoredHistoryCurrentURL = nil refreshNavigationAvailability() + noteSessionPersistenceHistoryChangedIfNeeded(reason: "history.abandon") } private static func serializableSessionHistoryURLString(_ url: URL?) -> String? { @@ -5623,6 +5658,44 @@ 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)) @@ -5630,9 +5703,14 @@ private extension BrowserPanel { 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 diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 5c1bfbb17..fb17995f3 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -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 @@ -365,12 +365,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 @@ -379,15 +381,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 { diff --git a/Sources/SidebarSelectionState.swift b/Sources/SidebarSelectionState.swift index 78ea1ab51..d0d2a2709 100644 --- a/Sources/SidebarSelectionState.swift +++ b/Sources/SidebarSelectionState.swift @@ -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 diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index a5f066b37..4cb998f2b 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -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 = [] @Published private(set) var debugPinnedWorkspaceLoadIds: Set = [] @@ -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 @@ -913,6 +925,7 @@ class TabManager: ObservableObject { } didSet { guard selectedTabId != oldValue else { return } + markSessionSnapshotDirty(reason: "selectedWorkspace") sentryBreadcrumb("workspace.switch", data: [ "tabCount": tabs.count ]) @@ -6740,49 +6753,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.terminalScrollBarHidden) - 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 } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 91ea1f269..4aa71e7c0 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -3,6 +3,7 @@ import SwiftUI import AppKit import Bonsplit import Combine +import Observation import CryptoKit import Darwin import Network @@ -440,12 +441,10 @@ extension Workspace { let branchSnapshot = panelGitBranches[panelId].map { SessionGitBranchSnapshot(branch: $0.branch, isDirty: $0.isDirty) } - let listeningPorts: [Int] - if remoteDetectedSurfaceIds.contains(panelId) || isRemoteTerminalSurface(panelId) { - listeningPorts = [] - } else { - listeningPorts = (surfaceListeningPorts[panelId] ?? []).sorted() - } + let listeningPorts = persistedSessionSnapshotListeningPorts( + for: panelId, + source: surfaceListeningPorts + ) let ttyName = surfaceTTYNames[panelId] let terminalSnapshot: SessionTerminalPanelSnapshot? @@ -512,6 +511,30 @@ extension Workspace { ) } + private func shouldPersistListeningPortsInSessionSnapshot(for panelId: UUID) -> Bool { + !remoteDetectedSurfaceIds.contains(panelId) && !isRemoteTerminalSurface(panelId) + } + + private func persistedSessionSnapshotListeningPorts( + for panelId: UUID, + source: [UUID: [Int]] + ) -> [Int] { + guard shouldPersistListeningPortsInSessionSnapshot(for: panelId) else { return [] } + return (source[panelId] ?? []).sorted() + } + + private func persistedSessionSnapshotListeningPortsSubset( + from source: [UUID: [Int]] + ) -> [UUID: [Int]] { + var subset: [UUID: [Int]] = [:] + for (panelId, _) in source { + let persistedPorts = persistedSessionSnapshotListeningPorts(for: panelId, source: source) + guard !persistedPorts.isEmpty else { continue } + subset[panelId] = persistedPorts + } + return subset + } + nonisolated static func resolvedSnapshotTerminalScrollback( capturedScrollback: String?, fallbackScrollback: String?, @@ -6482,19 +6505,93 @@ struct ClosedBrowserPanelRestoreSnapshot { /// Each workspace contains one BonsplitController that manages split panes and nested surfaces. @MainActor final class Workspace: Identifiable, ObservableObject { + private struct SessionPaneTabOrderSnapshot: Equatable { + let paneId: String + let tabIds: [String] + } + static let terminalScrollBarHiddenDidChangeNotification = Notification.Name( "cmux.workspaceTerminalScrollBarHiddenDidChange" ) let id: UUID @Published var title: String - @Published var customTitle: String? - @Published var customDescription: String? - @Published var isPinned: Bool = false - @Published var customColor: String? // hex string, e.g. "#C0392B" - @Published private(set) var terminalScrollBarHidden: Bool = false - @Published var currentDirectory: String + @Published var customTitle: String? { + didSet { + guard customTitle != oldValue else { return } + markSessionSnapshotDirty(reason: "customTitle") + } + } + @Published var customDescription: String? { + didSet { + guard customDescription != oldValue else { return } + markSessionSnapshotDirty(reason: "customDescription") + } + } + @Published var isPinned: Bool = false { + didSet { + guard isPinned != oldValue else { return } + markSessionSnapshotDirty(reason: "isPinned") + } + } + @Published var customColor: String? { // hex string, e.g. "#C0392B" + didSet { + guard customColor != oldValue else { return } + markSessionSnapshotDirty(reason: "customColor") + } + } + @Published var currentDirectory: String { + didSet { + guard currentDirectory != oldValue else { return } + markSessionSnapshotDirty(reason: "currentDirectory") + } + } + @Published private(set) var terminalScrollBarHidden: Bool = false { + didSet { + guard terminalScrollBarHidden != oldValue else { return } + markSessionSnapshotDirty(reason: "terminalScrollBarHidden") + } + } private(set) var preferredBrowserProfileID: UUID? + private var observedSessionPaneTabOrderSnapshot: [SessionPaneTabOrderSnapshot] = [] + + private func markSessionSnapshotDirty(reason: String) { + AppDelegate.requestSessionSnapshotDirty(reason: "workspace.\(reason)") + } + + private func currentSessionPaneTabOrderSnapshot() -> [SessionPaneTabOrderSnapshot] { + bonsplitController.layoutSnapshot().panes.map { pane in + SessionPaneTabOrderSnapshot(paneId: pane.paneId, tabIds: pane.tabIds) + } + } + + private func installSessionPaneTabOrderObserver() { + observedSessionPaneTabOrderSnapshot = currentSessionPaneTabOrderSnapshot() + observeSessionPaneTabOrderChanges() + } + + private func observeSessionPaneTabOrderChanges() { + _ = withObservationTracking { + currentSessionPaneTabOrderSnapshot() + } onChange: { [weak self] in + Task { @MainActor [weak self] in + self?.handleSessionPaneTabOrderChange() + } + } + } + + private func handleSessionPaneTabOrderChange() { + let previousSnapshot = observedSessionPaneTabOrderSnapshot + let nextSnapshot = currentSessionPaneTabOrderSnapshot() + observedSessionPaneTabOrderSnapshot = nextSnapshot + observeSessionPaneTabOrderChanges() + + guard nextSnapshot != previousSnapshot else { return } + + // Same-pane tab drags inside Bonsplit can reorder tabs without going through + // Workspace.reorderSurface or Bonsplit's geometry delegate callback. + markSessionSnapshotDirty(reason: "bonsplitTabOrder") + } /// Ordinal for CMUX_PORT range assignment (monotonically increasing per app session) var portOrdinal: Int = 0 @@ -6503,7 +6600,14 @@ final class Workspace: Identifiable, ObservableObject { let bonsplitController: BonsplitController /// Mapping from bonsplit TabID to our Panel instances - @Published private(set) var panels: [UUID: any Panel] = [:] + @Published private(set) var panels: [UUID: any Panel] = [:] { + didSet { + let oldPanelIds = oldValue.keys.sorted { $0.uuidString < $1.uuidString } + let newPanelIds = panels.keys.sorted { $0.uuidString < $1.uuidString } + guard oldPanelIds != newPanelIds else { return } + markSessionSnapshotDirty(reason: "panels") + } + } /// Subscriptions for panel updates (e.g., browser title changes) private var panelSubscriptions: [UUID: AnyCancellable] = [:] @@ -6557,11 +6661,36 @@ final class Workspace: Identifiable, ObservableObject { } /// Published directory for each panel - @Published var panelDirectories: [UUID: String] = [:] - @Published var panelTitles: [UUID: String] = [:] - @Published private(set) var panelCustomTitles: [UUID: String] = [:] - @Published private(set) var pinnedPanelIds: Set = [] - @Published private(set) var manualUnreadPanelIds: Set = [] + @Published var panelDirectories: [UUID: String] = [:] { + didSet { + guard panelDirectories != oldValue else { return } + markSessionSnapshotDirty(reason: "panelDirectories") + } + } + @Published var panelTitles: [UUID: String] = [:] { + didSet { + guard panelTitles != oldValue else { return } + markSessionSnapshotDirty(reason: "panelTitles") + } + } + @Published private(set) var panelCustomTitles: [UUID: String] = [:] { + didSet { + guard panelCustomTitles != oldValue else { return } + markSessionSnapshotDirty(reason: "panelCustomTitles") + } + } + @Published private(set) var pinnedPanelIds: Set = [] { + didSet { + guard pinnedPanelIds != oldValue else { return } + markSessionSnapshotDirty(reason: "pinnedPanels") + } + } + @Published private(set) var manualUnreadPanelIds: Set = [] { + didSet { + guard manualUnreadPanelIds != oldValue else { return } + markSessionSnapshotDirty(reason: "manualUnreadPanels") + } + } @Published private(set) var tmuxLayoutSnapshot: LayoutSnapshot? @Published private(set) var tmuxWorkspaceFlashPanelId: UUID? @Published private(set) var tmuxWorkspaceFlashReason: WorkspaceAttentionFlashReason? @@ -6569,17 +6698,51 @@ final class Workspace: Identifiable, ObservableObject { private var manualUnreadMarkedAt: [UUID: Date] = [:] nonisolated private static let manualUnreadFocusGraceInterval: TimeInterval = 0.2 nonisolated private static let manualUnreadClearDelayAfterFocusFlash: TimeInterval = 0.2 + // Runtime-only sidebar status is restored empty, so it should not drive autosave. @Published var statusEntries: [String: SidebarStatusEntry] = [:] @Published var metadataBlocks: [String: SidebarMetadataBlock] = [:] - @Published var logEntries: [SidebarLogEntry] = [] - @Published var progress: SidebarProgressState? - @Published var gitBranch: SidebarGitBranchState? - @Published var panelGitBranches: [UUID: SidebarGitBranchState] = [:] + @Published var logEntries: [SidebarLogEntry] = [] { + didSet { + guard logEntries != oldValue else { return } + markSessionSnapshotDirty(reason: "logEntries") + } + } + @Published var progress: SidebarProgressState? { + didSet { + guard progress != oldValue else { return } + markSessionSnapshotDirty(reason: "progress") + } + } + @Published var gitBranch: SidebarGitBranchState? { + didSet { + guard gitBranch != oldValue else { return } + markSessionSnapshotDirty(reason: "gitBranch") + } + } + @Published var panelGitBranches: [UUID: SidebarGitBranchState] = [:] { + didSet { + guard panelGitBranches != oldValue else { return } + markSessionSnapshotDirty(reason: "panelGitBranches") + } + } @Published var pullRequest: SidebarPullRequestState? @Published var panelPullRequests: [UUID: SidebarPullRequestState] = [:] - @Published var surfaceListeningPorts: [UUID: [Int]] = [:] + @Published var surfaceListeningPorts: [UUID: [Int]] = [:] { + didSet { + guard persistedSessionSnapshotListeningPortsSubset(from: surfaceListeningPorts) + != persistedSessionSnapshotListeningPortsSubset(from: oldValue) else { + return + } + markSessionSnapshotDirty(reason: "surfaceListeningPorts") + } + } var agentListeningPorts: [Int] = [] - @Published var remoteConfiguration: WorkspaceRemoteConfiguration? + @Published var remoteConfiguration: WorkspaceRemoteConfiguration? { + didSet { + guard (remoteConfiguration != nil) != (oldValue != nil) else { return } + markSessionSnapshotDirty(reason: "remoteConfiguration") + } + } @Published var remoteConnectionState: WorkspaceRemoteConnectionState = .disconnected @Published var remoteConnectionDetail: String? @Published var remoteDaemonStatus: WorkspaceRemoteDaemonStatus = WorkspaceRemoteDaemonStatus() @@ -6591,7 +6754,12 @@ final class Workspace: Identifiable, ObservableObject { @Published var remoteLastHeartbeatAt: Date? @Published var listeningPorts: [Int] = [] @Published private(set) var activeRemoteTerminalSessionCount: Int = 0 - var surfaceTTYNames: [UUID: String] = [:] + var surfaceTTYNames: [UUID: String] = [:] { + didSet { + guard surfaceTTYNames != oldValue else { return } + markSessionSnapshotDirty(reason: "surfaceTTYNames") + } + } private var remoteSessionController: WorkspaceRemoteSessionController? private var pendingRemoteForegroundAuthToken: String? fileprivate var activeRemoteSessionControllerID: UUID? @@ -6710,7 +6878,12 @@ final class Workspace: Identifiable, ObservableObject { set { panelDirectories = newValue } } - private var processTitle: String + private var processTitle: String { + didSet { + guard processTitle != oldValue else { return } + markSessionSnapshotDirty(reason: "processTitle") + } + } private enum SurfaceKind { static let terminal = "terminal" @@ -6966,6 +7139,7 @@ final class Workspace: Identifiable, ObservableObject { bonsplitController.selectTab(initialTabId) } tmuxLayoutSnapshot = bonsplitController.layoutSnapshot() + installSessionPaneTabOrderObserver() } deinit { @@ -7134,6 +7308,12 @@ final class Workspace: Identifiable, ObservableObject { private func installBrowserPanelSubscription(_ browserPanel: BrowserPanel) { + browserPanel.onSessionPersistenceStateChange = { [weak self, weak browserPanel] reason in + guard let self, let browserPanel else { return } + guard self.panels[browserPanel.id] != nil else { return } + self.markSessionSnapshotDirty(reason: "browser.\(reason)") + } + let subscription = Publishers.CombineLatest3( browserPanel.$pageTitle.removeDuplicates(), browserPanel.$isLoading.removeDuplicates(), @@ -9661,6 +9841,7 @@ final class Workspace: Identifiable, ObservableObject { guard let tabId = surfaceIdFromPanelId(panelId) else { return false } guard bonsplitController.allPaneIds.contains(paneId) else { return false } guard bonsplitController.moveTab(tabId, toPane: paneId, atIndex: index) else { return false } + markSessionSnapshotDirty(reason: "moveSurface") if focus { bonsplitController.focusPane(paneId) @@ -9677,6 +9858,7 @@ final class Workspace: Identifiable, ObservableObject { func reorderSurface(panelId: UUID, toIndex index: Int) -> Bool { guard let tabId = surfaceIdFromPanelId(panelId) else { return false } guard bonsplitController.reorderTab(tabId, toIndex: index) else { return false } + markSessionSnapshotDirty(reason: "reorderSurface") if let paneId = paneId(forPanelId: panelId) { applyTabSelection(tabId: tabId, inPane: paneId) @@ -11327,6 +11509,9 @@ extension Workspace: BonsplitDelegate { guard let panel = panels[effectiveFocusedPanelId] else { return } + if previousFocusedPanelId != effectiveFocusedPanelId { + markSessionSnapshotDirty(reason: "focusedPanel") + } if debugStressPreloadSelectionDepth > 0 { if let terminalPanel = panel as? TerminalPanel { @@ -12204,6 +12389,7 @@ extension Workspace: BonsplitDelegate { func splitTabBar(_ controller: BonsplitController, didChangeGeometry snapshot: LayoutSnapshot) { tmuxLayoutSnapshot = snapshot + markSessionSnapshotDirty(reason: "splitGeometry") scheduleTerminalGeometryReconcile() if !isDetachingCloseTransaction { scheduleFocusReconcile() diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index a8b58f7d3..228b3e9d9 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -1,4 +1,5 @@ import XCTest +@testable import Bonsplit #if canImport(cmux_DEV) @testable import cmux_DEV @@ -72,6 +73,56 @@ final class SessionPersistenceTests: XCTestCase { XCTAssertTrue(panelSnapshot.listeningPorts.isEmpty) } + @MainActor + func testTabManagerSessionSnapshotReflectsRemoteWorkspaceTransitions() throws { + let tabManager = TabManager() + let workspace = try XCTUnwrap(tabManager.tabs.first) + + XCTAssertEqual(tabManager.sessionSnapshot(includeScrollback: false).workspaces.count, 1) + + let configuration = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: nil, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: 64001, + relayID: "relay-test", + relayToken: String(repeating: "c", count: 64), + localSocketPath: "/tmp/cmux-test.sock", + terminalStartupCommand: "ssh cmux-macmini" + ) + + workspace.remoteConfiguration = configuration + XCTAssertEqual(tabManager.sessionSnapshot(includeScrollback: false).workspaces.count, 0) + + workspace.remoteConfiguration = nil + XCTAssertEqual(tabManager.sessionSnapshot(includeScrollback: false).workspaces.count, 1) + } + + @MainActor + func testDirectBonsplitSamePaneReorderMarksSessionDirty() throws { + let workspace = Workspace() + let paneId = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first) + _ = try XCTUnwrap(workspace.newTerminalSurface(inPane: paneId, focus: false)) + + let pane = try XCTUnwrap(workspace.bonsplitController.internalController.rootNode.findPane(paneId)) + let originalOrder = pane.tabs.map(\.id) + XCTAssertEqual(originalOrder.count, 2) + + let dirtyExpectation = expectation(description: "session dirty after same-pane bonsplit reorder") + AppDelegate.sessionSnapshotDirtyRequestObserverForTesting = { reason in + guard reason == "workspace.bonsplitTabOrder" else { return } + dirtyExpectation.fulfill() + } + defer { AppDelegate.sessionSnapshotDirtyRequestObserverForTesting = nil } + + pane.moveTab(from: 0, to: 2) + + wait(for: [dirtyExpectation], timeout: 1.0) + XCTAssertEqual(pane.tabs.map(\.id), [originalOrder[1], originalOrder[0]]) + } + func testSaveAndLoadRoundTripWithCustomSnapshotPath() throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true) @@ -490,56 +541,46 @@ final class SessionPersistenceTests: XCTestCase { ) } - func testUnchangedAutosaveFingerprintSkipsWithinStalenessWindow() { - let now = Date() + func testSessionAutosaveUsesThirtySecondDefaultInterval() { + XCTAssertEqual(SessionPersistencePolicy.autosaveInterval, 30.0, accuracy: 0.001) + } + + func testSessionAutosaveScheduleKeepsExistingDeadlineWhenDelayExtensionIsDisabled() { XCTAssertTrue( - AppDelegate.shouldSkipSessionAutosaveForUnchangedFingerprint( - isTerminatingApp: false, - includeScrollback: false, - previousFingerprint: 1234, - currentFingerprint: 1234, - lastPersistedAt: now.addingTimeInterval(-5), - now: now, - maximumAutosaveSkippableInterval: 60 + AppDelegate.shouldKeepExistingSessionAutosaveSchedule( + allowDelayExtension: false, + existingDeadlineUptime: 200, + targetDeadlineUptime: 230 ) ) } - func testUnchangedAutosaveFingerprintDoesNotSkipAfterStalenessWindow() { - let now = Date() + func testSessionAutosaveScheduleAcceptsEarlierDeadlineWhenDelayExtensionIsDisabled() { XCTAssertFalse( - AppDelegate.shouldSkipSessionAutosaveForUnchangedFingerprint( - isTerminatingApp: false, - includeScrollback: false, - previousFingerprint: 1234, - currentFingerprint: 1234, - lastPersistedAt: now.addingTimeInterval(-120), - now: now, - maximumAutosaveSkippableInterval: 60 + AppDelegate.shouldKeepExistingSessionAutosaveSchedule( + allowDelayExtension: false, + existingDeadlineUptime: 200, + targetDeadlineUptime: 170 ) ) } - func testUnchangedAutosaveFingerprintNeverSkipsTerminatingOrScrollbackWrites() { - let now = Date() + func testSessionAutosaveScheduleAllowsDelayExtensionWhenRequested() { XCTAssertFalse( - AppDelegate.shouldSkipSessionAutosaveForUnchangedFingerprint( - isTerminatingApp: true, - includeScrollback: false, - previousFingerprint: 1234, - currentFingerprint: 1234, - lastPersistedAt: now.addingTimeInterval(-1), - now: now + AppDelegate.shouldKeepExistingSessionAutosaveSchedule( + allowDelayExtension: true, + existingDeadlineUptime: 200, + targetDeadlineUptime: 230 ) ) - XCTAssertFalse( - AppDelegate.shouldSkipSessionAutosaveForUnchangedFingerprint( - isTerminatingApp: false, - includeScrollback: true, - previousFingerprint: 1234, - currentFingerprint: 1234, - lastPersistedAt: now.addingTimeInterval(-1), - now: now + } + + func testSessionAutosaveScheduleKeepsWhenDeadlinesAreEqual() { + XCTAssertTrue( + AppDelegate.shouldKeepExistingSessionAutosaveSchedule( + allowDelayExtension: false, + existingDeadlineUptime: 200, + targetDeadlineUptime: 200 ) ) }