From 06cd25ed526b2de2ac1e49605ec71dbf2e3e108f Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:20:19 -0800 Subject: [PATCH] Fix session restore routing and browser history persistence --- Sources/AppDelegate.swift | 275 +++++++++++++++++- Sources/Panels/BrowserPanel.swift | 167 ++++++++++- Sources/SessionPersistence.swift | 2 + Sources/Workspace.swift | 13 +- .../AppDelegateShortcutRoutingTests.swift | 236 +++++++++++++++ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 66 +++++ cmuxTests/SessionPersistenceTests.swift | 136 ++++++++- 7 files changed, 878 insertions(+), 17 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 4e31697b..9f53b63e 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -684,6 +684,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var startupSessionSnapshot: AppSessionSnapshot? private var didPrepareStartupSessionSnapshot = false private var didAttemptStartupSessionRestore = false + private var isApplyingStartupSessionRestore = false private var sessionAutosaveTimer: DispatchSourceTimer? private var didHandleExplicitOpenIntentAtStartup = false private var isTerminatingApp = false @@ -1037,6 +1038,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let startupSnapshot = startupSessionSnapshot let primaryWindowSnapshot = startupSnapshot?.windows.first if let primaryWindowSnapshot { + isApplyingStartupSessionRestore = true +#if DEBUG + dlog( + "session.restore.start windows=\(startupSnapshot?.windows.count ?? 0) " + + "primaryFrame={\(debugSessionRectDescription(primaryWindowSnapshot.frame))} " + + "primaryDisplay={\(debugSessionDisplayDescription(primaryWindowSnapshot.display))}" + ) +#endif applySessionWindowSnapshot( primaryWindowSnapshot, to: primaryContext, @@ -1057,21 +1066,37 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } if let startupSnapshot { - let additionalWindows = startupSnapshot + let additionalWindows = Array(startupSnapshot .windows .dropFirst() - .prefix(max(0, SessionPersistencePolicy.maxWindowsPerSnapshot - 1)) + .prefix(max(0, SessionPersistencePolicy.maxWindowsPerSnapshot - 1))) +#if DEBUG + for (index, windowSnapshot) in additionalWindows.enumerated() { + dlog( + "session.restore.enqueueAdditional idx=\(index + 1) " + + "frame={\(debugSessionRectDescription(windowSnapshot.frame))} " + + "display={\(debugSessionDisplayDescription(windowSnapshot.display))}" + ) + } +#endif if !additionalWindows.isEmpty { DispatchQueue.main.async { [weak self] in guard let self else { return } for windowSnapshot in additionalWindows { _ = self.createMainWindow(sessionWindowSnapshot: windowSnapshot) } + self.completeStartupSessionRestore() } + } else { + completeStartupSessionRestore() } } + } - self.startupSessionSnapshot = nil + private func completeStartupSessionRestore() { + startupSessionSnapshot = nil + isApplyingStartupSessionRestore = false + _ = saveSessionSnapshot(includeScrollback: false) } private func applySessionWindowSnapshot( @@ -1079,6 +1104,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent to context: MainWindowContext, window: NSWindow? ) { +#if DEBUG + dlog( + "session.restore.apply window=\(context.windowId.uuidString.prefix(8)) " + + "liveWin=\(window?.windowNumber ?? -1) " + + "snapshotFrame={\(debugSessionRectDescription(snapshot.frame))} " + + "snapshotDisplay={\(debugSessionDisplayDescription(snapshot.display))}" + ) +#endif context.tabManager.restoreSessionSnapshot(snapshot.tabManager) context.sidebarState.isVisible = snapshot.sidebar.isVisible context.sidebarState.persistedWidth = CGFloat( @@ -1088,6 +1121,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if let restoredFrame = resolvedWindowFrame(from: snapshot), let window { window.setFrame(restoredFrame, display: true) +#if DEBUG + dlog( + "session.restore.frameApplied window=\(context.windowId.uuidString.prefix(8)) " + + "applied={\(debugNSRectDescription(window.frame))}" + ) +#endif } } @@ -1150,6 +1189,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard !availableDisplays.isEmpty else { return frame } if let targetDisplay = display(for: displaySnapshot, in: availableDisplays) { + if shouldPreserveExactFrame( + frame: frame, + displaySnapshot: displaySnapshot, + targetDisplay: targetDisplay + ) { + return frame + } return resolvedWindowFrame( frame: frame, displaySnapshot: displaySnapshot, @@ -1339,6 +1385,45 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return (dx * dx) + (dy * dy) } + private nonisolated static func shouldPreserveExactFrame( + frame: CGRect, + displaySnapshot: SessionDisplaySnapshot?, + targetDisplay: SessionDisplayGeometry + ) -> Bool { + guard let displaySnapshot else { return false } + guard let snapshotDisplayID = displaySnapshot.displayID, + let targetDisplayID = targetDisplay.displayID, + snapshotDisplayID == targetDisplayID else { + return false + } + + let visibleMatches = displaySnapshot.visibleFrame.map { + rectApproximatelyEqual($0.cgRect, targetDisplay.visibleFrame) + } ?? false + let frameMatches = displaySnapshot.frame.map { + rectApproximatelyEqual($0.cgRect, targetDisplay.frame) + } ?? false + guard visibleMatches || frameMatches else { return false } + + return frame.width.isFinite + && frame.height.isFinite + && frame.origin.x.isFinite + && frame.origin.y.isFinite + } + + private nonisolated static func rectApproximatelyEqual( + _ lhs: CGRect, + _ rhs: CGRect, + tolerance: CGFloat = 1 + ) -> Bool { + let lhsStd = lhs.standardized + let rhsStd = rhs.standardized + return abs(lhsStd.origin.x - rhsStd.origin.x) <= tolerance + && abs(lhsStd.origin.y - rhsStd.origin.y) <= tolerance + && abs(lhsStd.size.width - rhsStd.size.width) <= tolerance + && abs(lhsStd.size.height - rhsStd.size.height) <= tolerance + } + private func displaySnapshot(for window: NSWindow?) -> SessionDisplaySnapshot? { guard let window else { return nil } let screen = window.screen @@ -1421,6 +1506,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult private func saveSessionSnapshot(includeScrollback: Bool, removeWhenEmpty: Bool = false) -> Bool { + if Self.shouldSkipSessionSaveDuringStartupRestore( + isApplyingStartupSessionRestore: isApplyingStartupSessionRestore, + includeScrollback: includeScrollback + ) { +#if DEBUG + dlog("session.save.skipped reason=startup_restore_in_progress includeScrollback=0") +#endif + return false + } guard let snapshot = buildSessionSnapshot(includeScrollback: includeScrollback) else { if removeWhenEmpty { SessionPersistenceStore.removeSnapshot() @@ -1433,6 +1527,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent display: primaryWindow.display ) } +#if DEBUG + debugLogSessionSaveSnapshot(snapshot, includeScrollback: includeScrollback) +#endif return SessionPersistenceStore.save(snapshot) } @@ -1446,6 +1543,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent !isTerminatingApp } + nonisolated static func shouldSkipSessionSaveDuringStartupRestore( + isApplyingStartupSessionRestore: Bool, + includeScrollback: Bool + ) -> Bool { + isApplyingStartupSessionRestore && !includeScrollback + } + private func buildSessionSnapshot(includeScrollback: Bool) -> AppSessionSnapshot? { let contexts = mainWindowContexts.values.sorted { lhs, rhs in let lhsWindow = lhs.window ?? windowForMainWindowId(lhs.windowId) @@ -1484,6 +1588,54 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } +#if DEBUG + private func debugLogSessionSaveSnapshot( + _ snapshot: AppSessionSnapshot, + includeScrollback: Bool + ) { + dlog( + "session.save includeScrollback=\(includeScrollback ? 1 : 0) " + + "windows=\(snapshot.windows.count)" + ) + for (index, windowSnapshot) in snapshot.windows.enumerated() { + let workspaceCount = windowSnapshot.tabManager.workspaces.count + let selectedWorkspace = windowSnapshot.tabManager.selectedWorkspaceIndex.map(String.init) ?? "nil" + dlog( + "session.save.window idx=\(index) " + + "frame={\(debugSessionRectDescription(windowSnapshot.frame))} " + + "display={\(debugSessionDisplayDescription(windowSnapshot.display))} " + + "workspaces=\(workspaceCount) selected=\(selectedWorkspace)" + ) + } + } + + private func debugSessionRectDescription(_ rect: SessionRectSnapshot?) -> String { + guard let rect else { return "nil" } + return "x=\(debugSessionNumber(rect.x)) y=\(debugSessionNumber(rect.y)) " + + "w=\(debugSessionNumber(rect.width)) h=\(debugSessionNumber(rect.height))" + } + + private func debugNSRectDescription(_ rect: NSRect?) -> String { + guard let rect else { return "nil" } + return "x=\(debugSessionNumber(Double(rect.origin.x))) " + + "y=\(debugSessionNumber(Double(rect.origin.y))) " + + "w=\(debugSessionNumber(Double(rect.size.width))) " + + "h=\(debugSessionNumber(Double(rect.size.height)))" + } + + private func debugSessionDisplayDescription(_ display: SessionDisplaySnapshot?) -> String { + guard let display else { return "nil" } + let displayIdText = display.displayID.map(String.init) ?? "nil" + return "id=\(displayIdText) " + + "frame={\(debugSessionRectDescription(display.frame))} " + + "visible={\(debugSessionRectDescription(display.visibleFrame))}" + } + + private func debugSessionNumber(_ value: Double) -> String { + String(format: "%.1f", value) + } +#endif + /// Register a terminal window with the AppDelegate so menu commands and socket control /// can target whichever window is currently active. func registerMainWindow( @@ -2186,9 +2338,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return context } + // If a keyboard event identifies a specific window but that context + // can't be resolved, do not fall back to another window. + if shortcutEventHasAddressableWindow(event) { +#if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "event_context_required_no_fallback", + event: event, + chosenContext: nil + ) +#endif + return nil + } + if let keyWindow = NSApp.keyWindow, let context = contextForMainTerminalWindow(keyWindow) { - #if DEBUG +#if DEBUG logWorkspaceCreationRouting( phase: "choose", source: debugSource, @@ -2238,10 +2405,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent event: event, chosenContext: fallback ) - #endif +#endif return fallback } + private func shortcutEventHasAddressableWindow(_ event: NSEvent?) -> Bool { + guard let event else { return false } + return event.window != nil || event.windowNumber >= 0 + } + private func mainWindowContext( forShortcutEvent event: NSEvent?, debugSource: String = "unspecified" @@ -2306,6 +2478,76 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return nil } + private func preferredMainWindowContextForShortcutRouting(event: NSEvent) -> MainWindowContext? { + if let context = mainWindowContext(forShortcutEvent: event, debugSource: "shortcut.routing") { + return context + } + + if shortcutEventHasAddressableWindow(event) { +#if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: "shortcut.routing", + reason: "event_context_required_no_fallback", + event: event, + chosenContext: nil + ) +#endif + return nil + } + + if let keyWindow = NSApp.keyWindow, + let context = contextForMainTerminalWindow(keyWindow) { + return context + } + + if let mainWindow = NSApp.mainWindow, + let context = contextForMainTerminalWindow(mainWindow) { + return context + } + + if let activeManager = tabManager, + let context = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { + return context + } + + return mainWindowContexts.values.first + } + + @discardableResult + private func synchronizeShortcutRoutingContext(event: NSEvent) -> Bool { + guard let context = preferredMainWindowContextForShortcutRouting(event: event) else { +#if DEBUG + FocusLogStore.shared.append( + "shortcut.route reason=no_context_no_fallback eventWin=\(event.windowNumber) keyCode=\(event.keyCode)" + ) +#endif + return false + } + + let alreadyActive = + tabManager === context.tabManager + && sidebarState === context.sidebarState + && sidebarSelectionState === context.sidebarSelectionState + if alreadyActive { return true } + + if let window = context.window ?? windowForMainWindowId(context.windowId) { + setActiveMainWindow(window) + } else { + tabManager = context.tabManager + sidebarState = context.sidebarState + sidebarSelectionState = context.sidebarSelectionState + TerminalController.shared.setActiveTabManager(context.tabManager) + } + +#if DEBUG + FocusLogStore.shared.append( + "shortcut.route reason=sync activeTM=\(pointerString(tabManager)) chosen={\(summarizeContextForWorkspaceRouting(context))}" + ) +#endif + return true + } + @discardableResult func createMainWindow( initialWorkingDirectory: String? = nil, @@ -2346,7 +2588,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent window.titlebarAppearsTransparent = true window.isMovableByWindowBackground = false window.isMovable = false - if let restoredFrame = resolvedWindowFrame(from: sessionWindowSnapshot) { + let restoredFrame = resolvedWindowFrame(from: sessionWindowSnapshot) + if let restoredFrame { window.setFrame(restoredFrame, display: false) } else { window.center() @@ -2384,6 +2627,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent setActiveMainWindow(window) NSApp.activate(ignoringOtherApps: true) } + if let restoredFrame { + window.setFrame(restoredFrame, display: true) +#if DEBUG + dlog( + "session.restore.frameApplied window=\(windowId.uuidString.prefix(8)) " + + "applied={\(debugNSRectDescription(window.frame))}" + ) +#endif + } return windowId } @@ -3500,9 +3752,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - // Route all shortcut handling through the window that actually produced - // the event to avoid cross-window actions when app-global pointers are stale. - activateMainWindowContextForShortcutEvent(event) + let hasEventWindowContext = shortcutEventHasAddressableWindow(event) + let didSynchronizeShortcutContext = synchronizeShortcutRoutingContext(event: event) + if hasEventWindowContext && !didSynchronizeShortcutContext { +#if DEBUG + dlog("handleCustomShortcut: unresolved event window context; bypassing app shortcut handling") +#endif + return false + } // Keep keyboard routing deterministic after split close/reparent transitions: // before processing shortcuts, converge first responder with the focused terminal panel. diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 420ba6d1..443d1f94 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1186,6 +1186,13 @@ final class BrowserPanel: Panel, ObservableObject { /// Published can go forward state @Published private(set) var canGoForward: Bool = false + 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? + /// Published estimated progress (0.0 - 1.0) @Published private(set) var estimatedProgress: Double = 0.0 @@ -1388,6 +1395,43 @@ final class BrowserPanel: Panel, ObservableObject { focusFlashToken &+= 1 } + func sessionNavigationHistorySnapshot() -> ( + backHistoryURLStrings: [String], + forwardHistoryURLStrings: [String] + ) { + if usesRestoredSessionHistory { + let back = restoredBackHistoryStack.compactMap { Self.serializableSessionHistoryURLString($0) } + // `restoredForwardHistoryStack` stores nearest-forward entries at the end. + let forward = restoredForwardHistoryStack.reversed().compactMap { Self.serializableSessionHistoryURLString($0) } + return (back, forward) + } + + let back = webView.backForwardList.backList.compactMap { + Self.serializableSessionHistoryURLString($0.url) + } + let forward = webView.backForwardList.forwardList.compactMap { + Self.serializableSessionHistoryURLString($0.url) + } + return (back, forward) + } + + func restoreSessionNavigationHistory( + backHistoryURLStrings: [String], + forwardHistoryURLStrings: [String], + currentURLString: String? + ) { + let restoredBack = Self.sanitizedSessionHistoryURLs(backHistoryURLStrings) + let restoredForward = Self.sanitizedSessionHistoryURLs(forwardHistoryURLStrings) + guard !restoredBack.isEmpty || !restoredForward.isEmpty else { return } + + usesRestoredSessionHistory = true + restoredBackHistoryStack = restoredBack + // Store nearest-forward entries at the end to make stack pop operations trivial. + restoredForwardHistoryStack = Array(restoredForward.reversed()) + restoredHistoryCurrentURL = Self.sanitizedSessionHistoryURL(currentURLString) + refreshNavigationAvailability() + } + private func setupObservers() { // URL changes let urlObserver = webView.observe(\.url, options: [.new]) { [weak self] webView, _ in @@ -1421,7 +1465,9 @@ final class BrowserPanel: Panel, ObservableObject { // Can go back let backObserver = webView.observe(\.canGoBack, options: [.new]) { [weak self] webView, _ in Task { @MainActor in - self?.canGoBack = webView.canGoBack + guard let self else { return } + self.nativeCanGoBack = webView.canGoBack + self.refreshNavigationAvailability() } } webViewObservers.append(backObserver) @@ -1429,7 +1475,9 @@ final class BrowserPanel: Panel, ObservableObject { // Can go forward let forwardObserver = webView.observe(\.canGoForward, options: [.new]) { [weak self] webView, _ in Task { @MainActor in - self?.canGoForward = webView.canGoForward + guard let self else { return } + self.nativeCanGoForward = webView.canGoForward + self.refreshNavigationAvailability() } } webViewObservers.append(forwardObserver) @@ -1692,13 +1740,28 @@ final class BrowserPanel: Panel, ObservableObject { navigateWithoutInsecureHTTPPrompt(request: request, recordTypedNavigation: recordTypedNavigation) } - private func navigateWithoutInsecureHTTPPrompt(to url: URL, recordTypedNavigation: Bool) { + private func navigateWithoutInsecureHTTPPrompt( + to url: URL, + recordTypedNavigation: Bool, + preserveRestoredSessionHistory: Bool = false + ) { let request = URLRequest(url: url) - navigateWithoutInsecureHTTPPrompt(request: request, recordTypedNavigation: recordTypedNavigation) + navigateWithoutInsecureHTTPPrompt( + request: request, + recordTypedNavigation: recordTypedNavigation, + preserveRestoredSessionHistory: preserveRestoredSessionHistory + ) } - private func navigateWithoutInsecureHTTPPrompt(request: URLRequest, recordTypedNavigation: Bool) { + private func navigateWithoutInsecureHTTPPrompt( + request: URLRequest, + recordTypedNavigation: Bool, + preserveRestoredSessionHistory: Bool = false + ) { guard let url = request.url else { return } + if !preserveRestoredSessionHistory { + abandonRestoredSessionHistoryIfNeeded() + } // Some installs can end up with a legacy Chrome UA override; keep this pinned. webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent shouldRenderWebView = true @@ -1843,12 +1906,48 @@ extension BrowserPanel { /// Go back in history func goBack() { guard canGoBack else { return } + if usesRestoredSessionHistory { + guard let targetURL = restoredBackHistoryStack.popLast() else { + refreshNavigationAvailability() + return + } + if let current = resolvedCurrentSessionHistoryURL() { + restoredForwardHistoryStack.append(current) + } + restoredHistoryCurrentURL = targetURL + refreshNavigationAvailability() + navigateWithoutInsecureHTTPPrompt( + to: targetURL, + recordTypedNavigation: false, + preserveRestoredSessionHistory: true + ) + return + } + webView.goBack() } /// Go forward in history func goForward() { guard canGoForward else { return } + if usesRestoredSessionHistory { + guard let targetURL = restoredForwardHistoryStack.popLast() else { + refreshNavigationAvailability() + return + } + if let current = resolvedCurrentSessionHistoryURL() { + restoredBackHistoryStack.append(current) + } + restoredHistoryCurrentURL = targetURL + refreshNavigationAvailability() + navigateWithoutInsecureHTTPPrompt( + to: targetURL, + recordTypedNavigation: false, + preserveRestoredSessionHistory: true + ) + return + } + webView.goForward() } @@ -2185,6 +2284,64 @@ extension BrowserPanel { return nil } + private func resolvedCurrentSessionHistoryURL() -> URL? { + if let webViewURL = webView.url, + Self.serializableSessionHistoryURLString(webViewURL) != nil { + return webViewURL + } + if let currentURL, + Self.serializableSessionHistoryURLString(currentURL) != nil { + return currentURL + } + return restoredHistoryCurrentURL + } + + private func refreshNavigationAvailability() { + let resolvedCanGoBack: Bool + let resolvedCanGoForward: Bool + if usesRestoredSessionHistory { + resolvedCanGoBack = !restoredBackHistoryStack.isEmpty + resolvedCanGoForward = !restoredForwardHistoryStack.isEmpty + } else { + resolvedCanGoBack = nativeCanGoBack + resolvedCanGoForward = nativeCanGoForward + } + + if canGoBack != resolvedCanGoBack { + canGoBack = resolvedCanGoBack + } + if canGoForward != resolvedCanGoForward { + canGoForward = resolvedCanGoForward + } + } + + private func abandonRestoredSessionHistoryIfNeeded() { + guard usesRestoredSessionHistory else { return } + usesRestoredSessionHistory = false + restoredBackHistoryStack.removeAll(keepingCapacity: false) + restoredForwardHistoryStack.removeAll(keepingCapacity: false) + restoredHistoryCurrentURL = nil + refreshNavigationAvailability() + } + + private static func serializableSessionHistoryURLString(_ url: URL?) -> String? { + guard let url else { return nil } + let value = url.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty, value != "about:blank" else { return nil } + return value + } + + private static func sanitizedSessionHistoryURL(_ raw: String?) -> URL? { + guard let raw else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed != "about:blank" else { return nil } + return URL(string: trimmed) + } + + private static func sanitizedSessionHistoryURLs(_ values: [String]) -> [URL] { + values.compactMap { sanitizedSessionHistoryURL($0) } + } + } private extension BrowserPanel { diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index d660d467..289909df 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -231,6 +231,8 @@ struct SessionBrowserPanelSnapshot: Codable, Sendable { var shouldRenderWebView: Bool var pageZoom: Double var developerToolsVisible: Bool + var backHistoryURLStrings: [String]? + var forwardHistoryURLStrings: [String]? } struct SessionPanelSnapshot: Codable, Sendable { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 1fda66ce..1140c6e3 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -295,11 +295,14 @@ extension Workspace { case .browser: guard let browserPanel = panel as? BrowserPanel else { return nil } terminalSnapshot = nil + let historySnapshot = browserPanel.sessionNavigationHistorySnapshot() browserSnapshot = SessionBrowserPanelSnapshot( - urlString: browserPanel.currentURL?.absoluteString, + urlString: browserPanel.preferredURLStringForOmnibar(), shouldRenderWebView: browserPanel.shouldRenderWebView, pageZoom: Double(browserPanel.webView.pageZoom), - developerToolsVisible: browserPanel.isDeveloperToolsVisible() + developerToolsVisible: browserPanel.isDeveloperToolsVisible(), + backHistoryURLStrings: historySnapshot.backHistoryURLStrings, + forwardHistoryURLStrings: historySnapshot.forwardHistoryURLStrings ) } @@ -512,6 +515,12 @@ extension Workspace { if let browserSnapshot = snapshot.browser, let browserPanel = browserPanel(for: panelId) { + browserPanel.restoreSessionNavigationHistory( + backHistoryURLStrings: browserSnapshot.backHistoryURLStrings ?? [], + forwardHistoryURLStrings: browserSnapshot.forwardHistoryURLStrings ?? [], + currentURLString: browserSnapshot.urlString + ) + let pageZoom = CGFloat(max(0.25, min(5.0, browserSnapshot.pageZoom))) if pageZoom.isFinite { browserPanel.webView.pageZoom = pageZoom diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index dae9faff..59796d6d 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -201,6 +201,242 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertEqual(secondManager.tabs.count, secondCount + 1, "Menu-driven add workspace should still route to key window context when object-key lookup misses") } + func testCmdDigitRoutesToEventWindowWhenActiveManagerIsStale() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let firstWindowId = appDelegate.createMainWindow() + let secondWindowId = appDelegate.createMainWindow() + + defer { + closeWindow(withId: firstWindowId) + closeWindow(withId: secondWindowId) + } + + guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId), + let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId), + let secondWindow = window(withId: secondWindowId) else { + XCTFail("Expected both window contexts to exist") + return + } + + _ = firstManager.addTab(select: true) + _ = secondManager.addTab(select: true) + + guard let firstSelectedBefore = firstManager.selectedTabId, + let secondSelectedBefore = secondManager.selectedTabId else { + XCTFail("Expected selected tabs in both windows") + return + } + guard let secondFirstTabId = secondManager.tabs.first?.id else { + XCTFail("Expected at least one tab in second window") + return + } + + appDelegate.tabManager = firstManager + XCTAssertTrue(appDelegate.tabManager === firstManager) + + guard let event = makeKeyDownEvent( + key: "1", + modifiers: [.command], + keyCode: 18, // kVK_ANSI_1 + windowNumber: secondWindow.windowNumber + ) else { + XCTFail("Failed to construct Cmd+1 event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + XCTAssertEqual(firstManager.selectedTabId, firstSelectedBefore, "Cmd+1 must not select a tab in stale active window") + XCTAssertNotEqual(secondManager.selectedTabId, secondSelectedBefore, "Cmd+1 should change tab selection in event window") + XCTAssertEqual(secondManager.selectedTabId, secondFirstTabId, "Cmd+1 should select first tab in the event window") + XCTAssertTrue(appDelegate.tabManager === secondManager, "Shortcut routing should retarget active manager to event window") + } + + func testCmdTRoutesToEventWindowWhenActiveManagerIsStale() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let firstWindowId = appDelegate.createMainWindow() + let secondWindowId = appDelegate.createMainWindow() + + defer { + closeWindow(withId: firstWindowId) + closeWindow(withId: secondWindowId) + } + + guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId), + let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId), + let secondWindow = window(withId: secondWindowId), + let firstWorkspace = firstManager.selectedWorkspace, + let secondWorkspace = secondManager.selectedWorkspace else { + XCTFail("Expected both window contexts to exist") + return + } + + let firstSurfaceCount = firstWorkspace.panels.count + let secondSurfaceCount = secondWorkspace.panels.count + + appDelegate.tabManager = firstManager + XCTAssertTrue(appDelegate.tabManager === firstManager) + + guard let event = makeKeyDownEvent( + key: "t", + modifiers: [.command], + keyCode: 17, // kVK_ANSI_T + windowNumber: secondWindow.windowNumber + ) else { + XCTFail("Failed to construct Cmd+T event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + XCTAssertEqual(firstWorkspace.panels.count, firstSurfaceCount, "Cmd+T must not create a surface in stale active window") + XCTAssertEqual(secondWorkspace.panels.count, secondSurfaceCount + 1, "Cmd+T should create a surface in the event window") + XCTAssertTrue(appDelegate.tabManager === secondManager, "Shortcut routing should retarget active manager to event window") + } + + func testCmdDigitDoesNotFallbackToOtherWindowWhenEventWindowContextIsMissing() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let firstWindowId = appDelegate.createMainWindow() + let secondWindowId = appDelegate.createMainWindow() + + defer { + closeWindow(withId: firstWindowId) + closeWindow(withId: secondWindowId) + } + + guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId), + let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId), + let secondWindow = window(withId: secondWindowId) else { + XCTFail("Expected both window contexts to exist") + return + } + + _ = firstManager.addTab(select: true) + _ = secondManager.addTab(select: true) + guard let firstSelectedBefore = firstManager.selectedTabId, + let secondSelectedBefore = secondManager.selectedTabId else { + XCTFail("Expected selected tabs in both windows") + return + } + + secondWindow.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + // Force stale app-level manager to first window while keyboard event + // references no known window. + appDelegate.tabManager = firstManager + + guard let event = makeKeyDownEvent( + key: "1", + modifiers: [.command], + keyCode: 18, + windowNumber: Int.max + ) else { + XCTFail("Failed to construct Cmd+1 event") + return + } + +#if DEBUG + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + XCTAssertEqual(firstManager.selectedTabId, firstSelectedBefore, "Unresolved event window must not route Cmd+1 into stale manager") + XCTAssertEqual(secondManager.selectedTabId, secondSelectedBefore, "Unresolved event window must not route Cmd+1 into key/main fallback manager") + XCTAssertTrue(appDelegate.tabManager === firstManager, "Unresolved event window should not retarget active manager") + } + + func testCmdNDoesNotFallbackToOtherWindowWhenEventWindowContextIsMissing() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let firstWindowId = appDelegate.createMainWindow() + let secondWindowId = appDelegate.createMainWindow() + + defer { + closeWindow(withId: firstWindowId) + closeWindow(withId: secondWindowId) + } + + guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId), + let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId), + let secondWindow = window(withId: secondWindowId) else { + XCTFail("Expected both window contexts to exist") + return + } + + secondWindow.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + let firstCount = firstManager.tabs.count + let secondCount = secondManager.tabs.count + appDelegate.tabManager = firstManager + + guard let event = makeKeyDownEvent( + key: "n", + modifiers: [.command], + keyCode: 45, + windowNumber: Int.max + ) else { + XCTFail("Failed to construct Cmd+N event") + return + } + +#if DEBUG + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + XCTAssertEqual(firstManager.tabs.count, firstCount, "Unresolved event window must not create workspace in stale manager") + XCTAssertEqual(secondManager.tabs.count, secondCount, "Unresolved event window must not create workspace in fallback window") + XCTAssertTrue(appDelegate.tabManager === firstManager, "Unresolved event window should not retarget active manager") + } + + private func makeKeyDownEvent( + key: String, + modifiers: NSEvent.ModifierFlags, + keyCode: UInt16, + windowNumber: Int + ) -> NSEvent? { + NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: modifiers, + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: windowNumber, + context: nil, + characters: key, + charactersIgnoringModifiers: key, + isARepeat: false, + keyCode: keyCode + ) + } + private func window(withId windowId: UUID) -> NSWindow? { let identifier = "cmux.main.\(windowId.uuidString)" return NSApp.windows.first(where: { $0.identifier?.rawValue == identifier }) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index c875cf11..91160e05 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1024,6 +1024,72 @@ final class BrowserJavaScriptDialogDelegateTests: XCTestCase { } } +@MainActor +final class BrowserSessionHistoryRestoreTests: XCTestCase { + func testSessionNavigationHistorySnapshotUsesRestoredStacks() { + let panel = BrowserPanel(workspaceId: UUID()) + + panel.restoreSessionNavigationHistory( + backHistoryURLStrings: [ + "https://example.com/a", + "https://example.com/b" + ], + forwardHistoryURLStrings: [ + "https://example.com/d" + ], + currentURLString: "https://example.com/c" + ) + + XCTAssertTrue(panel.canGoBack) + XCTAssertTrue(panel.canGoForward) + + let snapshot = panel.sessionNavigationHistorySnapshot() + XCTAssertEqual( + snapshot.backHistoryURLStrings, + ["https://example.com/a", "https://example.com/b"] + ) + XCTAssertEqual( + snapshot.forwardHistoryURLStrings, + ["https://example.com/d"] + ) + } + + func testSessionNavigationHistoryBackAndForwardUpdateStacks() { + let panel = BrowserPanel(workspaceId: UUID()) + + panel.restoreSessionNavigationHistory( + backHistoryURLStrings: [ + "https://example.com/a", + "https://example.com/b" + ], + forwardHistoryURLStrings: [ + "https://example.com/d" + ], + currentURLString: "https://example.com/c" + ) + + panel.goBack() + let afterBack = panel.sessionNavigationHistorySnapshot() + XCTAssertEqual(afterBack.backHistoryURLStrings, ["https://example.com/a"]) + XCTAssertEqual( + afterBack.forwardHistoryURLStrings, + ["https://example.com/c", "https://example.com/d"] + ) + XCTAssertTrue(panel.canGoBack) + XCTAssertTrue(panel.canGoForward) + + panel.goForward() + let afterForward = panel.sessionNavigationHistorySnapshot() + XCTAssertEqual( + afterForward.backHistoryURLStrings, + ["https://example.com/a", "https://example.com/b"] + ) + XCTAssertEqual(afterForward.forwardHistoryURLStrings, ["https://example.com/d"]) + XCTAssertTrue(panel.canGoBack) + XCTAssertTrue(panel.canGoForward) + } +} + @MainActor final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { private final class FakeInspector: NSObject { diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 638e8794..cd98ab5a 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -7,7 +7,7 @@ import XCTest #endif final class SessionPersistenceTests: XCTestCase { - func testSaveAndLoadRoundTripWithCustomSnapshotPath() { + func testSaveAndLoadRoundTripWithCustomSnapshotPath() throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true) try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) @@ -23,6 +23,14 @@ final class SessionPersistenceTests: XCTestCase { XCTAssertEqual(loaded?.version, SessionSnapshotSchema.currentVersion) XCTAssertEqual(loaded?.windows.count, 1) XCTAssertEqual(loaded?.windows.first?.sidebar.selection, .tabs) + let frame = try XCTUnwrap(loaded?.windows.first?.frame) + XCTAssertEqual(frame.x, 10, accuracy: 0.001) + XCTAssertEqual(frame.y, 20, accuracy: 0.001) + XCTAssertEqual(frame.width, 900, accuracy: 0.001) + XCTAssertEqual(frame.height, 700, accuracy: 0.001) + XCTAssertEqual(loaded?.windows.first?.display?.displayID, 42) + let visibleFrame = try XCTUnwrap(loaded?.windows.first?.display?.visibleFrame) + XCTAssertEqual(visibleFrame.y, 25, accuracy: 0.001) } func testSaveAndLoadRoundTripPreservesWorkspaceCustomColor() { @@ -129,6 +137,56 @@ final class SessionPersistenceTests: XCTestCase { ) } + func testSessionRectSnapshotEncodesXYWidthHeightKeys() throws { + let snapshot = SessionRectSnapshot(x: 101.25, y: 202.5, width: 903.75, height: 704.5) + let data = try JSONEncoder().encode(snapshot) + let object = try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Double]) + + XCTAssertEqual(Set(object.keys), Set(["x", "y", "width", "height"])) + XCTAssertEqual(try XCTUnwrap(object["x"]), 101.25, accuracy: 0.001) + XCTAssertEqual(try XCTUnwrap(object["y"]), 202.5, accuracy: 0.001) + XCTAssertEqual(try XCTUnwrap(object["width"]), 903.75, accuracy: 0.001) + XCTAssertEqual(try XCTUnwrap(object["height"]), 704.5, accuracy: 0.001) + } + + func testSessionBrowserPanelSnapshotHistoryRoundTrip() throws { + let source = SessionBrowserPanelSnapshot( + urlString: "https://example.com/current", + shouldRenderWebView: true, + pageZoom: 1.2, + developerToolsVisible: true, + backHistoryURLStrings: [ + "https://example.com/a", + "https://example.com/b" + ], + forwardHistoryURLStrings: [ + "https://example.com/d" + ] + ) + + let data = try JSONEncoder().encode(source) + let decoded = try JSONDecoder().decode(SessionBrowserPanelSnapshot.self, from: data) + XCTAssertEqual(decoded.urlString, source.urlString) + XCTAssertEqual(decoded.backHistoryURLStrings, source.backHistoryURLStrings) + XCTAssertEqual(decoded.forwardHistoryURLStrings, source.forwardHistoryURLStrings) + } + + func testSessionBrowserPanelSnapshotHistoryDecodesWhenKeysAreMissing() throws { + let json = """ + { + "urlString": "https://example.com/current", + "shouldRenderWebView": true, + "pageZoom": 1.0, + "developerToolsVisible": false + } + """.data(using: .utf8)! + + let decoded = try JSONDecoder().decode(SessionBrowserPanelSnapshot.self, from: json) + XCTAssertEqual(decoded.urlString, "https://example.com/current") + XCTAssertNil(decoded.backHistoryURLStrings) + XCTAssertNil(decoded.forwardHistoryURLStrings) + } + func testScrollbackReplayEnvironmentWritesReplayFile() { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-scrollback-replay-\(UUID().uuidString)", isDirectory: true) @@ -284,6 +342,27 @@ final class SessionPersistenceTests: XCTestCase { ) } + func testShouldSkipSessionSaveDuringStartupRestorePolicy() { + XCTAssertTrue( + AppDelegate.shouldSkipSessionSaveDuringStartupRestore( + isApplyingStartupSessionRestore: true, + includeScrollback: false + ) + ) + XCTAssertFalse( + AppDelegate.shouldSkipSessionSaveDuringStartupRestore( + isApplyingStartupSessionRestore: true, + includeScrollback: true + ) + ) + XCTAssertFalse( + AppDelegate.shouldSkipSessionSaveDuringStartupRestore( + isApplyingStartupSessionRestore: false, + includeScrollback: false + ) + ) + } + func testResolvedWindowFramePrefersSavedDisplayIdentity() { let savedFrame = SessionRectSnapshot(x: 1_200, y: 100, width: 600, height: 400) let savedDisplay = SessionDisplaySnapshot( @@ -436,6 +515,61 @@ final class SessionPersistenceTests: XCTestCase { XCTAssertEqual(restored.height, 700, accuracy: 0.001) } + func testResolvedWindowFramePreservesExactGeometryWhenDisplayIsUnchanged() { + let savedFrame = SessionRectSnapshot(x: 1_303, y: -90, width: 1_280, height: 1_410) + let savedDisplay = SessionDisplaySnapshot( + displayID: 2, + frame: SessionRectSnapshot(x: 0, y: 0, width: 2_560, height: 1_440), + visibleFrame: SessionRectSnapshot(x: 0, y: 0, width: 2_560, height: 1_410) + ) + let display = AppDelegate.SessionDisplayGeometry( + displayID: 2, + frame: CGRect(x: 0, y: 0, width: 2_560, height: 1_440), + visibleFrame: CGRect(x: 0, y: 0, width: 2_560, height: 1_410) + ) + + let restored = AppDelegate.resolvedWindowFrame( + from: savedFrame, + display: savedDisplay, + availableDisplays: [display], + fallbackDisplay: display + ) + + XCTAssertNotNil(restored) + guard let restored else { return } + XCTAssertEqual(restored.minX, 1_303, accuracy: 0.001) + XCTAssertEqual(restored.minY, -90, accuracy: 0.001) + XCTAssertEqual(restored.width, 1_280, accuracy: 0.001) + XCTAssertEqual(restored.height, 1_410, accuracy: 0.001) + } + + func testResolvedWindowFrameClampsWhenDisplayGeometryChangesEvenWithSameDisplayID() { + let savedFrame = SessionRectSnapshot(x: 1_303, y: -90, width: 1_280, height: 1_410) + let savedDisplay = SessionDisplaySnapshot( + displayID: 2, + frame: SessionRectSnapshot(x: 0, y: 0, width: 2_560, height: 1_440), + visibleFrame: SessionRectSnapshot(x: 0, y: 0, width: 2_560, height: 1_410) + ) + let resizedDisplay = AppDelegate.SessionDisplayGeometry( + displayID: 2, + frame: CGRect(x: 0, y: 0, width: 1_920, height: 1_080), + visibleFrame: CGRect(x: 0, y: 0, width: 1_920, height: 1_050) + ) + + let restored = AppDelegate.resolvedWindowFrame( + from: savedFrame, + display: savedDisplay, + availableDisplays: [resizedDisplay], + fallbackDisplay: resizedDisplay + ) + + XCTAssertNotNil(restored) + guard let restored else { return } + XCTAssertTrue(resizedDisplay.visibleFrame.contains(restored)) + XCTAssertNotEqual(restored.minX, 1_303, "Changed display geometry should clamp/remap frame") + XCTAssertNotEqual(restored.minY, -90, "Changed display geometry should clamp/remap frame") + } + func testResolvedSnapshotTerminalScrollbackPrefersCaptured() { let resolved = Workspace.resolvedSnapshotTerminalScrollback( capturedScrollback: "captured-value",