diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 65cc12e6..57d28a2b 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -54,6 +54,7 @@ A5001208 /* UpdateTitlebarAccessory.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001218 /* UpdateTitlebarAccessory.swift */; }; A5001209 /* WindowToolbarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001219 /* WindowToolbarController.swift */; }; A5001240 /* WindowDecorationsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001241 /* WindowDecorationsController.swift */; }; + A5001600 /* SessionPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001601 /* SessionPersistence.swift */; }; A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; }; A5001230 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A5001231 /* Sparkle */; }; B9000002A1B2C3D4E5F60719 /* cmux.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000001A1B2C3D4E5F60719 /* cmux.swift */; }; @@ -75,6 +76,8 @@ F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; }; F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; }; F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; }; + F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; }; + F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -177,6 +180,7 @@ A5001222 /* WindowAccessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAccessor.swift; sourceTree = ""; }; A5001223 /* UpdateLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateLogStore.swift; sourceTree = ""; }; A5001241 /* WindowDecorationsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDecorationsController.swift; sourceTree = ""; }; + A5001601 /* SessionPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistence.swift; sourceTree = ""; }; 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = ""; }; C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = ""; }; A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -197,6 +201,8 @@ F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = ""; }; F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = ""; }; F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = ""; }; + F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = ""; }; + F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -345,6 +351,7 @@ A5001219 /* WindowToolbarController.swift */, A5001241 /* WindowDecorationsController.swift */, A5001222 /* WindowAccessor.swift */, + A5001601 /* SessionPersistence.swift */, ); path = Sources; sourceTree = ""; @@ -402,6 +409,8 @@ F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */, F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */, F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */, + F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */, + F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -574,6 +583,7 @@ A5001209 /* WindowToolbarController.swift in Sources */, A5001240 /* WindowDecorationsController.swift in Sources */, A500120C /* WindowAccessor.swift in Sources */, + A5001600 /* SessionPersistence.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -602,6 +612,8 @@ F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */, F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */, F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */, + F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */, + F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index 4f8c832f..070b33e9 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -23,6 +23,18 @@ _cmux_send() { fi } +_cmux_restore_scrollback_once() { + local path="${CMUX_RESTORE_SCROLLBACK_FILE:-}" + [[ -n "$path" ]] || return 0 + unset CMUX_RESTORE_SCROLLBACK_FILE + + if [[ -r "$path" ]]; then + /bin/cat -- "$path" 2>/dev/null || true + /bin/rm -f -- "$path" >/dev/null 2>&1 || true + fi +} +_cmux_restore_scrollback_once + # Throttle heavy work to avoid prompt latency. _CMUX_PWD_LAST_PWD="${_CMUX_PWD_LAST_PWD:-}" _CMUX_GIT_LAST_PWD="${_CMUX_GIT_LAST_PWD:-}" diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index 6c9575f0..3121788f 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -24,6 +24,18 @@ _cmux_send() { fi } +_cmux_restore_scrollback_once() { + local path="${CMUX_RESTORE_SCROLLBACK_FILE:-}" + [[ -n "$path" ]] || return 0 + unset CMUX_RESTORE_SCROLLBACK_FILE + + if [[ -r "$path" ]]; then + /bin/cat -- "$path" 2>/dev/null || true + /bin/rm -f -- "$path" >/dev/null 2>&1 || true + fi +} +_cmux_restore_scrollback_once + # Throttle heavy work to avoid prompt latency. typeset -g _CMUX_PWD_LAST_PWD="" typeset -g _CMUX_GIT_LAST_PWD="" diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 23197bba..0f149ffa 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -64,6 +64,14 @@ enum WorkspaceShortcutMapper { } } +private extension NSScreen { + var cmuxDisplayID: UInt32? { + let key = NSDeviceDescriptionKey("NSScreenNumber") + guard let value = deviceDescription[key] as? NSNumber else { return nil } + return value.uint32Value + } +} + func browserOmnibarSelectionDeltaForCommandNavigation( hasFocusedAddressBar: Bool, flags: NSEvent.ModifierFlags, @@ -186,12 +194,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + struct SessionDisplayGeometry { + let displayID: UInt32? + let frame: CGRect + let visibleFrame: CGRect + } + weak var tabManager: TabManager? weak var notificationStore: TerminalNotificationStore? weak var sidebarState: SidebarState? weak var fullscreenControlsViewModel: TitlebarControlsViewModel? weak var sidebarSelectionState: SidebarSelectionState? private var workspaceObserver: NSObjectProtocol? + private var lifecycleSnapshotObservers: [NSObjectProtocol] = [] private var windowKeyObserver: NSObjectProtocol? private var shortcutMonitor: Any? private var shortcutDefaultsObserver: NSObjectProtocol? @@ -231,6 +246,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var didSetupGotoSplitUITest = false private var gotoSplitUITestObservers: [NSObjectProtocol] = [] private var didSetupMultiWindowNotificationsUITest = false + // Keep debug-only windows alive when tests intentionally inject key mismatches. + private var debugDetachedContextWindows: [NSWindow] = [] private func childExitKeyboardProbePath() -> String? { let env = ProcessInfo.processInfo.environment @@ -272,11 +289,66 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var mainWindowContexts: [ObjectIdentifier: MainWindowContext] = [:] private var mainWindowControllers: [MainWindowController] = [] + private var startupSessionSnapshot: AppSessionSnapshot? + private var didPrepareStartupSessionSnapshot = false + private var didAttemptStartupSessionRestore = false + private var sessionAutosaveTimer: DispatchSourceTimer? + private var didHandleExplicitOpenIntentAtStartup = false + private var isTerminatingApp = false + private var didInstallLifecycleSnapshotObservers = false + private var didDisableSuddenTermination = false var updateViewModel: UpdateViewModel { updateController.viewModel } +#if DEBUG + private func pointerString(_ object: AnyObject?) -> String { + guard let object else { return "nil" } + return String(describing: Unmanaged.passUnretained(object).toOpaque()) + } + + private func summarizeContextForWorkspaceRouting(_ context: MainWindowContext?) -> String { + guard let context else { return "nil" } + let window = context.window ?? windowForMainWindowId(context.windowId) + let windowNumber = window?.windowNumber ?? -1 + let key = window?.isKeyWindow == true ? 1 : 0 + let main = window?.isMainWindow == true ? 1 : 0 + let visible = window?.isVisible == true ? 1 : 0 + let selected = context.tabManager.selectedTabId.map { String($0.uuidString.prefix(8)) } ?? "nil" + return "wid=\(context.windowId.uuidString.prefix(8)) win=\(windowNumber) key=\(key) main=\(main) vis=\(visible) tabs=\(context.tabManager.tabs.count) sel=\(selected) tm=\(pointerString(context.tabManager))" + } + + private func summarizeAllContextsForWorkspaceRouting() -> String { + guard !mainWindowContexts.isEmpty else { return "" } + return mainWindowContexts.values + .map { summarizeContextForWorkspaceRouting($0) } + .joined(separator: " | ") + } + + private func logWorkspaceCreationRouting( + phase: String, + source: String, + reason: String, + event: NSEvent?, + chosenContext: MainWindowContext?, + workspaceId: UUID? = nil, + workingDirectory: String? = nil + ) { + let eventWindowNumber = event?.window?.windowNumber ?? -1 + let eventNumber = event?.windowNumber ?? -1 + let eventChars = event?.charactersIgnoringModifiers ?? "" + let eventKeyCode = event.map { String($0.keyCode) } ?? "nil" + let keyWindowNumber = NSApp.keyWindow?.windowNumber ?? -1 + let mainWindowNumber = NSApp.mainWindow?.windowNumber ?? -1 + let ws = workspaceId.map { String($0.uuidString.prefix(8)) } ?? "nil" + let wd = workingDirectory.map { String($0.prefix(120)) } ?? "-" + FocusLogStore.shared.append( + "cmdn.route phase=\(phase) src=\(source) reason=\(reason) eventWin=\(eventWindowNumber) eventNum=\(eventNumber) keyCode=\(eventKeyCode) chars=\(eventChars) keyWin=\(keyWindowNumber) mainWin=\(mainWindowNumber) activeTM=\(pointerString(tabManager)) chosen={\(summarizeContextForWorkspaceRouting(chosenContext))} ws=\(ws) wd=\(wd) contexts=[\(summarizeAllContextsForWorkspaceRouting())]" + ) + } +#endif + override init() { super.init() Self.shared = self @@ -433,17 +505,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId) } + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + isTerminatingApp = true + _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + return .terminateNow + } + func applicationWillTerminate(_ notification: Notification) { + isTerminatingApp = true + _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + stopSessionAutosaveTimer() TerminalController.shared.stop() BrowserHistoryStore.shared.flushPendingSaves() PostHogAnalytics.shared.flush() notificationStore?.clearAll() + enableSuddenTerminationIfNeeded() + } + + func applicationWillResignActive(_ notification: Notification) { + guard !isTerminatingApp else { return } + _ = saveSessionSnapshot(includeScrollback: false) + } + + func persistSessionForUpdateRelaunch() { + isTerminatingApp = true + _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) } func configure(tabManager: TabManager, notificationStore: TerminalNotificationStore, sidebarState: SidebarState) { self.tabManager = tabManager self.notificationStore = notificationStore self.sidebarState = sidebarState + disableSuddenTerminationIfNeeded() + installLifecycleSnapshotObserversIfNeeded() + prepareStartupSessionSnapshotIfNeeded() + startSessionAutosaveTimerIfNeeded() #if DEBUG setupJumpUnreadUITestIfNeeded() setupGotoSplitUITestIfNeeded() @@ -469,6 +565,431 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif } + private func prepareStartupSessionSnapshotIfNeeded() { + guard !didPrepareStartupSessionSnapshot else { return } + didPrepareStartupSessionSnapshot = true + guard SessionRestorePolicy.shouldAttemptRestore() else { return } + startupSessionSnapshot = SessionPersistenceStore.load() + } + + private func attemptStartupSessionRestoreIfNeeded(primaryWindow: NSWindow) { + guard !didAttemptStartupSessionRestore else { return } + didAttemptStartupSessionRestore = true + guard !didHandleExplicitOpenIntentAtStartup else { return } + guard let startupSessionSnapshot else { return } + guard let primaryContext = contextForMainTerminalWindow(primaryWindow) else { return } + guard let primaryWindowSnapshot = startupSessionSnapshot.windows.first else { return } + + applySessionWindowSnapshot( + primaryWindowSnapshot, + to: primaryContext, + window: primaryWindow + ) + + let additionalWindows = startupSessionSnapshot + .windows + .dropFirst() + .prefix(max(0, SessionPersistencePolicy.maxWindowsPerSnapshot - 1)) + if !additionalWindows.isEmpty { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + for windowSnapshot in additionalWindows { + _ = self.createMainWindow(sessionWindowSnapshot: windowSnapshot) + } + } + } + + self.startupSessionSnapshot = nil + } + + private func applySessionWindowSnapshot( + _ snapshot: SessionWindowSnapshot, + to context: MainWindowContext, + window: NSWindow? + ) { + context.tabManager.restoreSessionSnapshot(snapshot.tabManager) + context.sidebarState.isVisible = snapshot.sidebar.isVisible + context.sidebarState.persistedWidth = CGFloat( + SessionPersistencePolicy.sanitizedSidebarWidth(snapshot.sidebar.width) + ) + context.sidebarSelectionState.selection = snapshot.sidebar.selection.sidebarSelection + + if let restoredFrame = resolvedWindowFrame(from: snapshot), let window { + window.setFrame(restoredFrame, display: true) + } + } + + private func resolvedWindowFrame(from snapshot: SessionWindowSnapshot?) -> NSRect? { + let displays = NSScreen.screens.map { screen in + SessionDisplayGeometry( + displayID: screen.cmuxDisplayID, + frame: screen.frame, + visibleFrame: screen.visibleFrame + ) + } + let fallbackDisplay = (NSScreen.main ?? NSScreen.screens.first).map { screen in + SessionDisplayGeometry( + displayID: screen.cmuxDisplayID, + frame: screen.frame, + visibleFrame: screen.visibleFrame + ) + } + + return Self.resolvedWindowFrame( + from: snapshot?.frame, + display: snapshot?.display, + availableDisplays: displays, + fallbackDisplay: fallbackDisplay + ) + } + + nonisolated static func resolvedWindowFrame( + from frameSnapshot: SessionRectSnapshot?, + display displaySnapshot: SessionDisplaySnapshot?, + availableDisplays: [SessionDisplayGeometry], + fallbackDisplay: SessionDisplayGeometry? + ) -> CGRect? { + guard let frameSnapshot else { return nil } + let frame = frameSnapshot.cgRect + guard frame.width.isFinite, + frame.height.isFinite, + frame.origin.x.isFinite, + frame.origin.y.isFinite else { + return nil + } + + let minWidth = CGFloat(SessionPersistencePolicy.minimumWindowWidth) + let minHeight = CGFloat(SessionPersistencePolicy.minimumWindowHeight) + guard frame.width >= minWidth, + frame.height >= minHeight else { + return nil + } + + guard !availableDisplays.isEmpty else { return frame } + + if let targetDisplay = display(for: displaySnapshot, in: availableDisplays) { + return resolvedWindowFrame( + frame: frame, + displaySnapshot: displaySnapshot, + targetDisplay: targetDisplay, + minWidth: minWidth, + minHeight: minHeight + ) + } + + if let intersectingDisplay = availableDisplays.first(where: { $0.visibleFrame.intersects(frame) }) { + return clampFrame( + frame, + within: intersectingDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) + } + + guard let fallbackDisplay else { return frame } + if let sourceReference = displaySnapshot?.visibleFrame?.cgRect ?? displaySnapshot?.frame?.cgRect { + return remappedFrame( + frame, + from: sourceReference, + to: fallbackDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) + } + + return centeredFrame( + frame, + in: fallbackDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) + } + + private nonisolated static func resolvedWindowFrame( + frame: CGRect, + displaySnapshot: SessionDisplaySnapshot?, + targetDisplay: SessionDisplayGeometry, + minWidth: CGFloat, + minHeight: CGFloat + ) -> CGRect { + if targetDisplay.visibleFrame.intersects(frame) { + return clampFrame( + frame, + within: targetDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) + } + + if let sourceReference = displaySnapshot?.visibleFrame?.cgRect ?? displaySnapshot?.frame?.cgRect { + return remappedFrame( + frame, + from: sourceReference, + to: targetDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) + } + + return centeredFrame( + frame, + in: targetDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) + } + + private nonisolated static func display( + for snapshot: SessionDisplaySnapshot?, + in displays: [SessionDisplayGeometry] + ) -> SessionDisplayGeometry? { + guard let snapshot else { return nil } + if let displayID = snapshot.displayID, + let exact = displays.first(where: { $0.displayID == displayID }) { + return exact + } + + guard let referenceRect = (snapshot.visibleFrame ?? snapshot.frame)?.cgRect else { + return nil + } + + let overlaps = displays.map { display -> (display: SessionDisplayGeometry, area: CGFloat) in + (display, intersectionArea(referenceRect, display.visibleFrame)) + } + if let bestOverlap = overlaps.max(by: { $0.area < $1.area }), bestOverlap.area > 0 { + return bestOverlap.display + } + + let referenceCenter = CGPoint(x: referenceRect.midX, y: referenceRect.midY) + return displays.min { lhs, rhs in + let lhsDistance = distanceSquared(lhs.visibleFrame, referenceCenter) + let rhsDistance = distanceSquared(rhs.visibleFrame, referenceCenter) + return lhsDistance < rhsDistance + } + } + + private nonisolated static func remappedFrame( + _ frame: CGRect, + from sourceRect: CGRect, + to targetRect: CGRect, + minWidth: CGFloat, + minHeight: CGFloat + ) -> CGRect { + let source = sourceRect.standardized + let target = targetRect.standardized + guard source.width.isFinite, + source.height.isFinite, + source.width > 1, + source.height > 1, + target.width.isFinite, + target.height.isFinite, + target.width > 0, + target.height > 0 else { + return centeredFrame(frame, in: targetRect, minWidth: minWidth, minHeight: minHeight) + } + + let relativeX = (frame.minX - source.minX) / source.width + let relativeY = (frame.minY - source.minY) / source.height + let relativeWidth = frame.width / source.width + let relativeHeight = frame.height / source.height + + let remapped = CGRect( + x: target.minX + (relativeX * target.width), + y: target.minY + (relativeY * target.height), + width: target.width * relativeWidth, + height: target.height * relativeHeight + ) + return clampFrame(remapped, within: target, minWidth: minWidth, minHeight: minHeight) + } + + private nonisolated static func centeredFrame( + _ frame: CGRect, + in visibleFrame: CGRect, + minWidth: CGFloat, + minHeight: CGFloat + ) -> CGRect { + let centered = CGRect( + x: visibleFrame.midX - (frame.width / 2), + y: visibleFrame.midY - (frame.height / 2), + width: frame.width, + height: frame.height + ) + return clampFrame(centered, within: visibleFrame, minWidth: minWidth, minHeight: minHeight) + } + + private nonisolated static func clampFrame( + _ frame: CGRect, + within visibleFrame: CGRect, + minWidth: CGFloat, + minHeight: CGFloat + ) -> CGRect { + guard visibleFrame.width.isFinite, + visibleFrame.height.isFinite, + visibleFrame.width > 0, + visibleFrame.height > 0 else { + return frame + } + + let maxWidth = max(visibleFrame.width, 1) + let maxHeight = max(visibleFrame.height, 1) + let widthFloor = min(minWidth, maxWidth) + let heightFloor = min(minHeight, maxHeight) + + let width = min(max(frame.width, widthFloor), maxWidth) + let height = min(max(frame.height, heightFloor), maxHeight) + let maxX = visibleFrame.maxX - width + let maxY = visibleFrame.maxY - height + let x = min(max(frame.minX, visibleFrame.minX), maxX) + let y = min(max(frame.minY, visibleFrame.minY), maxY) + + return CGRect(x: x, y: y, width: width, height: height) + } + + private nonisolated static func intersectionArea(_ lhs: CGRect, _ rhs: CGRect) -> CGFloat { + let intersection = lhs.intersection(rhs) + guard !intersection.isNull else { return 0 } + return max(0, intersection.width) * max(0, intersection.height) + } + + private nonisolated static func distanceSquared(_ rect: CGRect, _ point: CGPoint) -> CGFloat { + let dx = rect.midX - point.x + let dy = rect.midY - point.y + return (dx * dx) + (dy * dy) + } + + private func displaySnapshot(for window: NSWindow?) -> SessionDisplaySnapshot? { + guard let window else { return nil } + let screen = window.screen + ?? NSScreen.screens.first(where: { $0.frame.intersects(window.frame) }) + guard let screen else { return nil } + + return SessionDisplaySnapshot( + displayID: screen.cmuxDisplayID, + frame: SessionRectSnapshot(screen.frame), + visibleFrame: SessionRectSnapshot(screen.visibleFrame) + ) + } + + private func startSessionAutosaveTimerIfNeeded() { + guard sessionAutosaveTimer == nil else { return } + let env = ProcessInfo.processInfo.environment + 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.setEventHandler { [weak self] in + _ = self?.saveSessionSnapshot(includeScrollback: false) + } + sessionAutosaveTimer = timer + timer.resume() + } + + private func stopSessionAutosaveTimer() { + sessionAutosaveTimer?.cancel() + sessionAutosaveTimer = nil + } + + private func installLifecycleSnapshotObserversIfNeeded() { + guard !didInstallLifecycleSnapshotObservers else { return } + didInstallLifecycleSnapshotObservers = true + + let workspaceCenter = NSWorkspace.shared.notificationCenter + let powerOffObserver = workspaceCenter.addObserver( + forName: NSWorkspace.willPowerOffNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self else { return } + self.isTerminatingApp = true + _ = self.saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + } + } + lifecycleSnapshotObservers.append(powerOffObserver) + + let sessionResignObserver = workspaceCenter.addObserver( + forName: NSWorkspace.sessionDidResignActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self else { return } + if self.isTerminatingApp { + _ = self.saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + } else { + _ = self.saveSessionSnapshot(includeScrollback: false) + } + } + } + lifecycleSnapshotObservers.append(sessionResignObserver) + } + + private func disableSuddenTerminationIfNeeded() { + guard !didDisableSuddenTermination else { return } + ProcessInfo.processInfo.disableSuddenTermination() + didDisableSuddenTermination = true + } + + private func enableSuddenTerminationIfNeeded() { + guard didDisableSuddenTermination else { return } + ProcessInfo.processInfo.enableSuddenTermination() + didDisableSuddenTermination = false + } + + @discardableResult + private func saveSessionSnapshot(includeScrollback: Bool, removeWhenEmpty: Bool = false) -> Bool { + guard let snapshot = buildSessionSnapshot(includeScrollback: includeScrollback) else { + if removeWhenEmpty { + SessionPersistenceStore.removeSnapshot() + } + return false + } + return SessionPersistenceStore.save(snapshot) + } + + nonisolated static func shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: Bool) -> Bool { + !isTerminatingApp + } + + private func buildSessionSnapshot(includeScrollback: Bool) -> AppSessionSnapshot? { + let contexts = mainWindowContexts.values.sorted { lhs, rhs in + let lhsWindow = lhs.window ?? windowForMainWindowId(lhs.windowId) + let rhsWindow = rhs.window ?? windowForMainWindowId(rhs.windowId) + let lhsIsKey = lhsWindow?.isKeyWindow ?? false + let rhsIsKey = rhsWindow?.isKeyWindow ?? false + if lhsIsKey != rhsIsKey { + return lhsIsKey && !rhsIsKey + } + return lhs.windowId.uuidString < rhs.windowId.uuidString + } + + guard !contexts.isEmpty else { return nil } + + let windows: [SessionWindowSnapshot] = contexts + .prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) + .map { context in + let window = context.window ?? windowForMainWindowId(context.windowId) + return SessionWindowSnapshot( + frame: window.map { SessionRectSnapshot($0.frame) }, + display: displaySnapshot(for: window), + tabManager: context.tabManager.sessionSnapshot(includeScrollback: includeScrollback), + sidebar: SessionSidebarSnapshot( + isVisible: context.sidebarState.isVisible, + selection: SessionSidebarSelection(selection: context.sidebarSelectionState.selection), + width: SessionPersistencePolicy.sanitizedSidebarWidth(Double(context.sidebarState.persistedWidth)) + ) + ) + } + + guard !windows.isEmpty else { return nil } + return AppSessionSnapshot( + version: SessionSnapshotSchema.currentVersion, + createdAt: Date().timeIntervalSince1970, + windows: windows + ) + } + /// Register a terminal window with the AppDelegate so menu commands and socket control /// can target whichever window is currently active. func registerMainWindow( @@ -481,6 +1002,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let key = ObjectIdentifier(window) if let existing = mainWindowContexts[key] { existing.window = window + } else if let existing = mainWindowContexts.values.first(where: { $0.windowId == windowId }) { + existing.window = window + reindexMainWindowContextIfNeeded(existing, for: window) } else { mainWindowContexts[key] = MainWindowContext( windowId: windowId, @@ -502,6 +1026,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if window.isKeyWindow { setActiveMainWindow(window) } + + attemptStartupSessionRestoreIfNeeded(primaryWindow: window) + if !isTerminatingApp { + _ = saveSessionSnapshot(includeScrollback: false) + } } struct MainWindowSummary { @@ -591,6 +1120,83 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier }) } + private func mainWindowId(from window: NSWindow) -> UUID? { + guard let raw = window.identifier?.rawValue else { return nil } + let prefix = "cmux.main." + guard raw.hasPrefix(prefix) else { return nil } + let suffix = String(raw.dropFirst(prefix.count)) + return UUID(uuidString: suffix) + } + + private func reindexMainWindowContextIfNeeded(_ context: MainWindowContext, for window: NSWindow) { + let desiredKey = ObjectIdentifier(window) + if mainWindowContexts[desiredKey] === context { + context.window = window + return + } + + let contextKeys = mainWindowContexts.compactMap { key, value in + value === context ? key : nil + } + for key in contextKeys { + mainWindowContexts.removeValue(forKey: key) + } + + if let conflicting = mainWindowContexts[desiredKey], conflicting !== context { + context.window = window + return + } + + mainWindowContexts[desiredKey] = context + context.window = window + } + + private func contextForMainTerminalWindow(_ window: NSWindow, reindex: Bool = true) -> MainWindowContext? { + guard isMainTerminalWindow(window) else { return nil } + + if let context = mainWindowContexts[ObjectIdentifier(window)] { + context.window = window + return context + } + + if let windowId = mainWindowId(from: window), + let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }) { + if reindex { + reindexMainWindowContextIfNeeded(context, for: window) + } else { + context.window = window + } + return context + } + + let windowNumber = window.windowNumber + if windowNumber >= 0, + let context = mainWindowContexts.values.first(where: { candidate in + let candidateWindow = candidate.window ?? windowForMainWindowId(candidate.windowId) + return candidateWindow?.windowNumber == windowNumber + }) { + if reindex { + reindexMainWindowContextIfNeeded(context, for: window) + } else { + context.window = window + } + return context + } + + return nil + } + + private func unregisterMainWindowContext(for window: NSWindow) -> MainWindowContext? { + guard let removed = contextForMainTerminalWindow(window, reindex: false) else { return nil } + let removedKeys = mainWindowContexts.compactMap { key, value in + value === removed ? key : nil + } + for key in removedKeys { + mainWindowContexts.removeValue(forKey: key) + } + return removed + } + @objc func openNewMainWindow(_ sender: Any?) { _ = createMainWindow() } @@ -621,6 +1227,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent target: ServiceOpenTarget, error: AutoreleasingUnsafeMutablePointer ) { + didHandleExplicitOpenIntentAtStartup = true + if !didAttemptStartupSessionRestore { + startupSessionSnapshot = nil + didAttemptStartupSessionRestore = true + } + let pathURLs = servicePathURLs(from: pasteboard) guard !pathURLs.isEmpty else { error.pointee = Self.serviceErrorNoPath @@ -672,38 +1284,222 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func openWorkspaceFromService(workingDirectory: String) { - if let context = preferredMainWindowContextForServiceWorkspace(), - let window = context.window ?? windowForMainWindowId(context.windowId) { - setActiveMainWindow(window) - bringToFront(window) - _ = context.tabManager.addWorkspace(workingDirectory: workingDirectory) + if addWorkspaceInPreferredMainWindow( + workingDirectory: workingDirectory, + shouldBringToFront: true, + debugSource: "service.openTab" + ) != nil { return } _ = createMainWindow(initialWorkingDirectory: workingDirectory) } - private func preferredMainWindowContextForServiceWorkspace() -> MainWindowContext? { + @discardableResult + func addWorkspaceInPreferredMainWindow( + workingDirectory: String? = nil, + shouldBringToFront: Bool = false, + event: NSEvent? = nil, + debugSource: String = "unspecified" + ) -> UUID? { + #if DEBUG + logWorkspaceCreationRouting( + phase: "request", + source: debugSource, + reason: "add_workspace", + event: event, + chosenContext: nil, + workingDirectory: workingDirectory + ) + #endif + guard let context = preferredMainWindowContextForWorkspaceCreation(event: event, debugSource: debugSource) else { + #if DEBUG + logWorkspaceCreationRouting( + phase: "no_context", + source: debugSource, + reason: "context_selection_failed", + event: event, + chosenContext: nil, + workingDirectory: workingDirectory + ) + #endif + return nil + } + if let window = context.window ?? windowForMainWindowId(context.windowId) { + setActiveMainWindow(window) + if shouldBringToFront { + bringToFront(window) + } + } + + let workspace: Workspace + if let workingDirectory { + workspace = context.tabManager.addWorkspace(workingDirectory: workingDirectory, select: true) + } else { + workspace = context.tabManager.addTab(select: true) + } + #if DEBUG + logWorkspaceCreationRouting( + phase: "created", + source: debugSource, + reason: "workspace_created", + event: event, + chosenContext: context, + workspaceId: workspace.id, + workingDirectory: workingDirectory + ) + #endif + return workspace.id + } + + private func preferredMainWindowContextForWorkspaceCreation( + event: NSEvent? = nil, + debugSource: String = "unspecified" + ) -> MainWindowContext? { + if let context = mainWindowContext(forShortcutEvent: event, debugSource: debugSource) { + return context + } + if let keyWindow = NSApp.keyWindow, - isMainTerminalWindow(keyWindow), - let context = mainWindowContexts[ObjectIdentifier(keyWindow)] { + let context = contextForMainTerminalWindow(keyWindow) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "key_window", + event: event, + chosenContext: context + ) + #endif return context } if let mainWindow = NSApp.mainWindow, - isMainTerminalWindow(mainWindow), - let context = mainWindowContexts[ObjectIdentifier(mainWindow)] { + let context = contextForMainTerminalWindow(mainWindow) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "main_window", + event: event, + chosenContext: context + ) + #endif return context } - return mainWindowContexts.values.first + for window in NSApp.orderedWindows where isMainTerminalWindow(window) { + if let context = contextForMainTerminalWindow(window) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "ordered_windows", + event: event, + chosenContext: context + ) + #endif + return context + } + } + + let fallback = mainWindowContexts.values.first + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "fallback_first_context", + event: event, + chosenContext: fallback + ) + #endif + return fallback + } + + private func mainWindowContext( + forShortcutEvent event: NSEvent?, + debugSource: String = "unspecified" + ) -> MainWindowContext? { + guard let event else { return nil } + + if let eventWindow = event.window, + let context = contextForMainTerminalWindow(eventWindow) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "event_window", + event: event, + chosenContext: context + ) + #endif + return context + } + + if event.windowNumber >= 0, + let numberedWindow = NSApp.window(withWindowNumber: event.windowNumber), + let context = contextForMainTerminalWindow(numberedWindow) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "event_window_number", + event: event, + chosenContext: context + ) + #endif + return context + } + + if event.windowNumber >= 0, + let context = mainWindowContexts.values.first(where: { candidate in + let window = candidate.window ?? windowForMainWindowId(candidate.windowId) + return window?.windowNumber == event.windowNumber + }) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "event_window_number_scan", + event: event, + chosenContext: context + ) + #endif + return context + } + + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "event_context_not_found", + event: event, + chosenContext: nil + ) + #endif + return nil } @discardableResult - func createMainWindow(initialWorkingDirectory: String? = nil) -> UUID { + func createMainWindow( + initialWorkingDirectory: String? = nil, + sessionWindowSnapshot: SessionWindowSnapshot? = nil + ) -> UUID { let windowId = UUID() let tabManager = TabManager(initialWorkingDirectory: initialWorkingDirectory) - let sidebarState = SidebarState() - let sidebarSelectionState = SidebarSelectionState() + if let tabManagerSnapshot = sessionWindowSnapshot?.tabManager { + tabManager.restoreSessionSnapshot(tabManagerSnapshot) + } + + let sidebarWidth = sessionWindowSnapshot?.sidebar.width + .map(SessionPersistencePolicy.sanitizedSidebarWidth) + ?? SessionPersistencePolicy.defaultSidebarWidth + let sidebarState = SidebarState( + isVisible: sessionWindowSnapshot?.sidebar.isVisible ?? true, + persistedWidth: CGFloat(sidebarWidth) + ) + let sidebarSelectionState = SidebarSelectionState( + selection: sessionWindowSnapshot?.sidebar.selection.sidebarSelection ?? .tabs + ) let notificationStore = TerminalNotificationStore.shared let root = ContentView(updateViewModel: updateViewModel, windowId: windowId) @@ -722,7 +1518,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent window.titleVisibility = .hidden window.titlebarAppearsTransparent = true window.isMovableByWindowBackground = false - window.center() + if let restoredFrame = resolvedWindowFrame(from: sessionWindowSnapshot) { + window.setFrame(restoredFrame, display: false) + } else { + window.center() + } window.contentView = NSHostingView(rootView: root) // Apply shared window styling. @@ -805,8 +1605,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func showNotificationsPopoverFromMenuBar() { let context: MainWindowContext? = { if let keyWindow = NSApp.keyWindow, - isMainTerminalWindow(keyWindow), - let keyContext = mainWindowContexts[ObjectIdentifier(keyWindow)] { + let keyContext = contextForMainTerminalWindow(keyWindow) { return keyContext } if let first = mainWindowContexts.values.first { @@ -1804,10 +2603,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Cmd+N semantics: // - If there are no main windows, create a new window. // - Otherwise, create a new workspace in the active window. - if tabManager == nil || mainWindowContexts.isEmpty { + if mainWindowContexts.isEmpty { + #if DEBUG + logWorkspaceCreationRouting( + phase: "fallback_new_window", + source: "shortcut.cmdN", + reason: "no_main_windows", + event: event, + chosenContext: nil + ) + #endif + openNewMainWindow(nil) + } else if addWorkspaceInPreferredMainWindow(event: event, debugSource: "shortcut.cmdN") == nil { + #if DEBUG + logWorkspaceCreationRouting( + phase: "fallback_new_window", + source: "shortcut.cmdN", + reason: "workspace_creation_returned_nil", + event: event, + chosenContext: nil + ) + #endif openNewMainWindow(nil) - } else { - tabManager?.addTab() } return true } @@ -2355,6 +3172,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func debugHandleCustomShortcut(event: NSEvent) -> Bool { handleCustomShortcut(event: event) } + + // Test hook: remap a window context under a detached window key so direct + // ObjectIdentifier(window) lookups fail and fallback logic is exercised. + @discardableResult + func debugInjectWindowContextKeyMismatch(windowId: UUID) -> Bool { + guard let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }), + let window = context.window ?? windowForMainWindowId(windowId) else { + return false + } + + let detachedWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 16, height: 16), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + debugDetachedContextWindows.append(detachedWindow) + + let contextKeys = mainWindowContexts.compactMap { key, value in + value === context ? key : nil + } + for key in contextKeys { + mainWindowContexts.removeValue(forKey: key) + } + mainWindowContexts[ObjectIdentifier(detachedWindow)] = context + context.window = window + return true + } #endif private func findButton(in view: NSView, titled title: String) -> NSButton? { @@ -2697,8 +3542,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func setActiveMainWindow(_ window: NSWindow) { - guard isMainTerminalWindow(window) else { return } - guard let context = mainWindowContexts[ObjectIdentifier(window)] else { return } + guard let context = contextForMainTerminalWindow(window) else { return } tabManager = context.tabManager sidebarState = context.sidebarState sidebarSelectionState = context.sidebarSelectionState @@ -2706,8 +3550,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func unregisterMainWindow(_ window: NSWindow) { - let key = ObjectIdentifier(window) - guard let removed = mainWindowContexts.removeValue(forKey: key) else { return } + guard let removed = unregisterMainWindowContext(for: window) else { return } // Avoid stale notifications that can no longer be opened once the owning window is gone. if let store = notificationStore { @@ -2720,8 +3563,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Repoint "active" pointers to any remaining main terminal window. let nextContext: MainWindowContext? = { if let keyWindow = NSApp.keyWindow, - isMainTerminalWindow(keyWindow), - let ctx = mainWindowContexts[ObjectIdentifier(keyWindow)] { + let ctx = contextForMainTerminalWindow(keyWindow, reindex: false) { return ctx } return mainWindowContexts.values.first @@ -2739,6 +3581,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent TerminalController.shared.setActiveTabManager(nil) } } + + // During app termination we already persisted a full snapshot (with scrollback) + // in applicationShouldTerminate/applicationWillTerminate. Saving again here would + // overwrite it as windows tear down one-by-one, dropping closed windows and replay. + if Self.shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: isTerminatingApp) { + _ = saveSessionSnapshot(includeScrollback: false) + } } private func isMainTerminalWindow(_ window: NSWindow) -> Bool { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 4ad00b12..53f9da9a 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -152,7 +152,14 @@ enum WindowGlassEffect { } final class SidebarState: ObservableObject { - @Published var isVisible: Bool = true + @Published var isVisible: Bool + @Published var persistedWidth: CGFloat + + init(isVisible: Bool = true, persistedWidth: CGFloat = CGFloat(SessionPersistencePolicy.defaultSidebarWidth)) { + self.isVisible = isVisible + let sanitized = SessionPersistencePolicy.sanitizedSidebarWidth(Double(persistedWidth)) + self.persistedWidth = CGFloat(sanitized) + } func toggle() { isVisible.toggle() @@ -844,6 +851,15 @@ struct ContentView: View { (NSApp.keyWindow?.screen?.frame.width ?? NSScreen.main?.frame.width ?? 1920) * 2 / 3 } + private func normalizedSidebarWidth(_ candidate: CGFloat) -> CGFloat { + let minWidth = CGFloat(SessionPersistencePolicy.minimumSidebarWidth) + let maxWidth = max(minWidth, maxSidebarWidth) + if !candidate.isFinite { + return CGFloat(SessionPersistencePolicy.defaultSidebarWidth) + } + return max(minWidth, min(maxWidth, candidate)) + } + private func activateSidebarResizerCursor() { sidebarResizerCursorReleaseWorkItem?.cancel() sidebarResizerCursorReleaseWorkItem = nil @@ -1317,6 +1333,13 @@ struct ContentView: View { reconcileMountedWorkspaceIds() previousSelectedWorkspaceId = tabManager.selectedTabId installSidebarResizerPointerMonitorIfNeeded() + let restoredWidth = normalizedSidebarWidth(sidebarState.persistedWidth) + if abs(sidebarWidth - restoredWidth) > 0.5 { + sidebarWidth = restoredWidth + } + if abs(sidebarState.persistedWidth - restoredWidth) > 0.5 { + sidebarState.persistedWidth = restoredWidth + } if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId { selectedTabIds = [selectedId] lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } @@ -1456,6 +1479,14 @@ struct ContentView: View { }) view = AnyView(view.onChange(of: sidebarWidth) { _ in + let sanitized = normalizedSidebarWidth(sidebarWidth) + if abs(sidebarWidth - sanitized) > 0.5 { + sidebarWidth = sanitized + return + } + if abs(sidebarState.persistedWidth - sanitized) > 0.5 { + sidebarState.persistedWidth = sanitized + } updateSidebarResizerBandState() }) @@ -1463,6 +1494,18 @@ struct ContentView: View { updateSidebarResizerBandState() }) + view = AnyView(view.onChange(of: sidebarState.persistedWidth) { newValue in + let sanitized = normalizedSidebarWidth(newValue) + if abs(newValue - sanitized) > 0.5 { + sidebarState.persistedWidth = sanitized + return + } + guard !isResizerDragging else { return } + if abs(sidebarWidth - sanitized) > 0.5 { + sidebarWidth = sanitized + } + }) + view = AnyView(view.ignoresSafeArea()) view = AnyView(view.onDisappear { diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 78121fa0..094518d5 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1126,6 +1126,7 @@ final class TerminalSurface: Identifiable, ObservableObject { private let surfaceContext: ghostty_surface_context_e private let configTemplate: ghostty_surface_config_s? private let workingDirectory: String? + private let additionalEnvironment: [String: String] let hostedView: GhosttySurfaceScrollView private let surfaceView: GhosttyNSView private var lastPixelWidth: UInt32 = 0 @@ -1170,13 +1171,15 @@ final class TerminalSurface: Identifiable, ObservableObject { tabId: UUID, context: ghostty_surface_context_e, configTemplate: ghostty_surface_config_s?, - workingDirectory: String? = nil + workingDirectory: String? = nil, + additionalEnvironment: [String: String] = [:] ) { self.id = UUID() self.tabId = tabId self.surfaceContext = context self.configTemplate = configTemplate self.workingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) + self.additionalEnvironment = additionalEnvironment // Match Ghostty's own SurfaceView: ensure a non-zero initial frame so the backing layer // has non-zero bounds and the renderer can initialize without presenting a blank/stretched // intermediate frame on the first real resize. @@ -1426,6 +1429,12 @@ final class TerminalSurface: Identifiable, ObservableObject { } } + if !additionalEnvironment.isEmpty { + for (key, value) in additionalEnvironment where !key.isEmpty && !value.isEmpty { + env[key] = value + } + } + if !env.isEmpty { envVars.reserveCapacity(env.count) envStorage.reserveCapacity(env.count) diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index b9a9d767..ede60c40 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -83,13 +83,15 @@ final class TerminalPanel: Panel, ObservableObject { context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: ghostty_surface_config_s? = nil, workingDirectory: String? = nil, + additionalEnvironment: [String: String] = [:], portOrdinal: Int = 0 ) { let surface = TerminalSurface( tabId: workspaceId, context: context, configTemplate: configTemplate, - workingDirectory: workingDirectory + workingDirectory: workingDirectory, + additionalEnvironment: additionalEnvironment ) surface.portOrdinal = portOrdinal self.init(workspaceId: workspaceId, surface: surface) diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift new file mode 100644 index 00000000..f4459a68 --- /dev/null +++ b/Sources/SessionPersistence.swift @@ -0,0 +1,471 @@ +import CoreGraphics +import Foundation +import Bonsplit + +enum SessionSnapshotSchema { + static let currentVersion = 1 +} + +enum SessionPersistencePolicy { + static let defaultSidebarWidth: Double = 200 + static let minimumSidebarWidth: Double = 186 + static let maximumSidebarWidth: Double = 600 + static let minimumWindowWidth: Double = 300 + static let minimumWindowHeight: Double = 200 + static let autosaveInterval: TimeInterval = 8.0 + static let maxWindowsPerSnapshot: Int = 12 + static let maxWorkspacesPerWindow: Int = 128 + static let maxPanelsPerWorkspace: Int = 512 + static let maxScrollbackLinesPerTerminal: Int = 4000 + static let maxScrollbackCharactersPerTerminal: Int = 400_000 + + static func sanitizedSidebarWidth(_ candidate: Double?) -> Double { + let fallback = defaultSidebarWidth + guard let candidate, candidate.isFinite else { return fallback } + return min(max(candidate, minimumSidebarWidth), maximumSidebarWidth) + } + + static func truncatedScrollback(_ text: String?) -> String? { + guard let text, !text.isEmpty else { return nil } + if text.count <= maxScrollbackCharactersPerTerminal { + return text + } + let initialStart = text.index(text.endIndex, offsetBy: -maxScrollbackCharactersPerTerminal) + let safeStart = ansiSafeTruncationStart(in: text, initialStart: initialStart) + return String(text[safeStart...]) + } + + /// If truncation starts in the middle of an ANSI CSI escape sequence, advance + /// to the first printable character after that sequence to avoid replaying + /// malformed control bytes. + private static func ansiSafeTruncationStart(in text: String, initialStart: String.Index) -> String.Index { + guard initialStart > text.startIndex else { return initialStart } + let escape = "\u{001B}" + + guard let lastEscape = text[.. String.Index? { + var index = text.index(after: csiMarker) + while index < upperBound { + guard let scalar = text[index].unicodeScalars.first?.value else { + index = text.index(after: index) + continue + } + if scalar >= 0x40, scalar <= 0x7E { + return index + } + index = text.index(after: index) + } + return nil + } +} + +enum SessionRestorePolicy { + static func isRunningUnderAutomatedTests( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> Bool { + if environment["CMUX_UI_TEST_MODE"] == "1" { + return true + } + if environment.keys.contains(where: { $0.hasPrefix("CMUX_UI_TEST_") }) { + return true + } + if environment["XCTestConfigurationFilePath"] != nil { + return true + } + if environment["XCTestBundlePath"] != nil { + return true + } + if environment["XCTestSessionIdentifier"] != nil { + return true + } + if environment["XCInjectBundle"] != nil { + return true + } + if environment["XCInjectBundleInto"] != nil { + return true + } + if environment["DYLD_INSERT_LIBRARIES"]?.contains("libXCTest") == true { + return true + } + return false + } + + static func shouldAttemptRestore( + arguments: [String] = CommandLine.arguments, + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> Bool { + if environment["CMUX_DISABLE_SESSION_RESTORE"] == "1" { + return false + } + if isRunningUnderAutomatedTests(environment: environment) { + return false + } + + let extraArgs = arguments + .dropFirst() + .filter { !$0.hasPrefix("-psn_") } + + // Any explicit launch argument is treated as an explicit open intent. + return extraArgs.isEmpty + } +} + +struct SessionRectSnapshot: Codable, Equatable, Sendable { + let x: Double + let y: Double + let width: Double + let height: Double + + init(x: Double, y: Double, width: Double, height: Double) { + self.x = x + self.y = y + self.width = width + self.height = height + } + + init(_ rect: CGRect) { + self.x = Double(rect.origin.x) + self.y = Double(rect.origin.y) + self.width = Double(rect.size.width) + self.height = Double(rect.size.height) + } + + var cgRect: CGRect { + CGRect(x: x, y: y, width: width, height: height) + } +} + +struct SessionDisplaySnapshot: Codable, Sendable { + var displayID: UInt32? + var frame: SessionRectSnapshot? + var visibleFrame: SessionRectSnapshot? +} + +enum SessionSidebarSelection: String, Codable, Sendable, Equatable { + case tabs + case notifications + + init(selection: SidebarSelection) { + switch selection { + case .tabs: + self = .tabs + case .notifications: + self = .notifications + } + } + + var sidebarSelection: SidebarSelection { + switch self { + case .tabs: + return .tabs + case .notifications: + return .notifications + } + } +} + +struct SessionSidebarSnapshot: Codable, Sendable { + var isVisible: Bool + var selection: SessionSidebarSelection + var width: Double? +} + +struct SessionStatusEntrySnapshot: Codable, Sendable { + var key: String + var value: String + var icon: String? + var color: String? + var timestamp: TimeInterval +} + +struct SessionLogEntrySnapshot: Codable, Sendable { + var message: String + var level: String + var source: String? + var timestamp: TimeInterval +} + +struct SessionProgressSnapshot: Codable, Sendable { + var value: Double + var label: String? +} + +struct SessionGitBranchSnapshot: Codable, Sendable { + var branch: String + var isDirty: Bool +} + +struct SessionTerminalPanelSnapshot: Codable, Sendable { + var workingDirectory: String? + var scrollback: String? +} + +struct SessionBrowserPanelSnapshot: Codable, Sendable { + var urlString: String? + var shouldRenderWebView: Bool + var pageZoom: Double + var developerToolsVisible: Bool +} + +struct SessionPanelSnapshot: Codable, Sendable { + var id: UUID + var type: PanelType + var title: String? + var customTitle: String? + var directory: String? + var isPinned: Bool + var isManuallyUnread: Bool + var gitBranch: SessionGitBranchSnapshot? + var listeningPorts: [Int] + var ttyName: String? + var terminal: SessionTerminalPanelSnapshot? + var browser: SessionBrowserPanelSnapshot? +} + +enum SessionSplitOrientation: String, Codable, Sendable { + case horizontal + case vertical + + init(_ orientation: SplitOrientation) { + switch orientation { + case .horizontal: + self = .horizontal + case .vertical: + self = .vertical + } + } + + var splitOrientation: SplitOrientation { + switch self { + case .horizontal: + return .horizontal + case .vertical: + return .vertical + } + } +} + +struct SessionPaneLayoutSnapshot: Codable, Sendable { + var panelIds: [UUID] + var selectedPanelId: UUID? +} + +struct SessionSplitLayoutSnapshot: Codable, Sendable { + var orientation: SessionSplitOrientation + var dividerPosition: Double + var first: SessionWorkspaceLayoutSnapshot + var second: SessionWorkspaceLayoutSnapshot +} + +indirect enum SessionWorkspaceLayoutSnapshot: Codable, Sendable { + case pane(SessionPaneLayoutSnapshot) + case split(SessionSplitLayoutSnapshot) + + private enum CodingKeys: String, CodingKey { + case type + case pane + case split + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + switch type { + case "pane": + self = .pane(try container.decode(SessionPaneLayoutSnapshot.self, forKey: .pane)) + case "split": + self = .split(try container.decode(SessionSplitLayoutSnapshot.self, forKey: .split)) + default: + throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unsupported layout node type: \(type)") + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .pane(let pane): + try container.encode("pane", forKey: .type) + try container.encode(pane, forKey: .pane) + case .split(let split): + try container.encode("split", forKey: .type) + try container.encode(split, forKey: .split) + } + } +} + +struct SessionWorkspaceSnapshot: Codable, Sendable { + var processTitle: String + var customTitle: String? + var isPinned: Bool + var currentDirectory: String + var focusedPanelId: UUID? + var layout: SessionWorkspaceLayoutSnapshot + var panels: [SessionPanelSnapshot] + var statusEntries: [SessionStatusEntrySnapshot] + var logEntries: [SessionLogEntrySnapshot] + var progress: SessionProgressSnapshot? + var gitBranch: SessionGitBranchSnapshot? +} + +struct SessionTabManagerSnapshot: Codable, Sendable { + var selectedWorkspaceIndex: Int? + var workspaces: [SessionWorkspaceSnapshot] +} + +struct SessionWindowSnapshot: Codable, Sendable { + var frame: SessionRectSnapshot? + var display: SessionDisplaySnapshot? + var tabManager: SessionTabManagerSnapshot + var sidebar: SessionSidebarSnapshot +} + +struct AppSessionSnapshot: Codable, Sendable { + var version: Int + var createdAt: TimeInterval + var windows: [SessionWindowSnapshot] +} + +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 + } + + @discardableResult + static func save(_ snapshot: AppSessionSnapshot, fileURL: URL? = nil) -> Bool { + guard let fileURL = fileURL ?? defaultSnapshotFileURL() else { return false } + let directory = fileURL.deletingLastPathComponent() + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(snapshot) + try data.write(to: fileURL, options: .atomic) + return true + } catch { + return false + } + } + + static func removeSnapshot(fileURL: URL? = nil) { + guard let fileURL = fileURL ?? defaultSnapshotFileURL() else { return } + try? FileManager.default.removeItem(at: fileURL) + } + + static func defaultSnapshotFileURL( + bundleIdentifier: String? = Bundle.main.bundleIdentifier, + appSupportDirectory: URL? = nil + ) -> URL? { + let resolvedAppSupport: URL + if let appSupportDirectory { + resolvedAppSupport = appSupportDirectory + } else if let discovered = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { + resolvedAppSupport = discovered + } else { + return nil + } + let bundleId = (bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) + ? bundleIdentifier! + : "com.cmuxterm.app" + let safeBundleId = bundleId.replacingOccurrences( + of: "[^A-Za-z0-9._-]", + with: "_", + options: .regularExpression + ) + return resolvedAppSupport + .appendingPathComponent("cmux", isDirectory: true) + .appendingPathComponent("session-\(safeBundleId).json", isDirectory: false) + } +} + +enum SessionScrollbackReplayStore { + static let environmentKey = "CMUX_RESTORE_SCROLLBACK_FILE" + private static let directoryName = "cmux-session-scrollback" + private static let ansiEscape = "\u{001B}" + private static let ansiReset = "\u{001B}[0m" + + static func replayEnvironment( + for scrollback: String?, + tempDirectory: URL = FileManager.default.temporaryDirectory + ) -> [String: String] { + guard let replayText = normalizedScrollback(scrollback) else { return [:] } + guard let replayFileURL = writeReplayFile( + contents: replayText, + tempDirectory: tempDirectory + ) else { + return [:] + } + return [environmentKey: replayFileURL.path] + } + + private static func normalizedScrollback(_ scrollback: String?) -> String? { + guard let scrollback else { return nil } + guard scrollback.contains(where: { !$0.isWhitespace }) else { return nil } + guard let truncated = SessionPersistencePolicy.truncatedScrollback(scrollback) else { return nil } + return ansiSafeReplayText(truncated) + } + + /// Preserve ANSI color state safely across replay boundaries. + private static func ansiSafeReplayText(_ text: String) -> String { + guard text.contains(ansiEscape) else { return text } + var output = text + if !output.hasPrefix(ansiReset) { + output = ansiReset + output + } + if !output.hasSuffix(ansiReset) { + output += ansiReset + } + return output + } + + private static func writeReplayFile(contents: String, tempDirectory: URL) -> URL? { + guard let data = contents.data(using: .utf8) else { return nil } + let directory = tempDirectory.appendingPathComponent(directoryName, isDirectory: true) + + do { + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: nil + ) + let fileURL = directory + .appendingPathComponent(UUID().uuidString, isDirectory: false) + .appendingPathExtension("txt") + try data.write(to: fileURL, options: .atomic) + return fileURL + } catch { + return nil + } + } +} diff --git a/Sources/SidebarSelectionState.swift b/Sources/SidebarSelectionState.swift index 6fed3117..78ea1ab5 100644 --- a/Sources/SidebarSelectionState.swift +++ b/Sources/SidebarSelectionState.swift @@ -2,6 +2,9 @@ import SwiftUI @MainActor final class SidebarSelectionState: ObservableObject { - @Published var selection: SidebarSelection = .tabs -} + @Published var selection: SidebarSelection + init(selection: SidebarSelection = .tabs) { + self.selection = selection + } +} diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 0bcd5ea6..cec81c03 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -2774,6 +2774,75 @@ class TabManager: ObservableObject { #endif } +extension TabManager { + func sessionSnapshot(includeScrollback: Bool) -> SessionTabManagerSnapshot { + let workspaceSnapshots = tabs + .prefix(SessionPersistencePolicy.maxWorkspacesPerWindow) + .map { $0.sessionSnapshot(includeScrollback: includeScrollback) } + let selectedWorkspaceIndex = selectedTabId.flatMap { selectedTabId in + tabs.firstIndex(where: { $0.id == selectedTabId }) + } + return SessionTabManagerSnapshot( + selectedWorkspaceIndex: selectedWorkspaceIndex, + workspaces: workspaceSnapshots + ) + } + + func restoreSessionSnapshot(_ snapshot: SessionTabManagerSnapshot) { + for tab in tabs { + unwireClosedBrowserTracking(for: tab) + } + + tabs.removeAll(keepingCapacity: false) + lastFocusedPanelByTab.removeAll() + pendingPanelTitleUpdates.removeAll() + tabHistory.removeAll() + historyIndex = -1 + isNavigatingHistory = false + pendingWorkspaceUnfocusTarget = nil + workspaceCycleCooldownTask?.cancel() + workspaceCycleCooldownTask = nil + isWorkspaceCycleHot = false + selectionSideEffectsGeneration &+= 1 + recentlyClosedBrowsers = RecentlyClosedBrowserStack(capacity: 20) + + let workspaceSnapshots = snapshot.workspaces + .prefix(SessionPersistencePolicy.maxWorkspacesPerWindow) + for workspaceSnapshot in workspaceSnapshots { + let ordinal = Self.nextPortOrdinal + Self.nextPortOrdinal += 1 + let workspace = Workspace( + title: workspaceSnapshot.processTitle, + workingDirectory: workspaceSnapshot.currentDirectory, + portOrdinal: ordinal + ) + workspace.restoreSessionSnapshot(workspaceSnapshot) + wireClosedBrowserTracking(for: workspace) + tabs.append(workspace) + } + + if tabs.isEmpty { + _ = addWorkspace(select: false) + } + + selectedTabId = nil + if let selectedWorkspaceIndex = snapshot.selectedWorkspaceIndex, + tabs.indices.contains(selectedWorkspaceIndex) { + selectedTabId = tabs[selectedWorkspaceIndex].id + } else { + selectedTabId = tabs.first?.id + } + + if let selectedTabId { + NotificationCenter.default.post( + name: .ghosttyDidFocusTab, + object: nil, + userInfo: [GhosttyNotificationKey.tabId: selectedTabId] + ) + } + } +} + // MARK: - Direction Types for Backwards Compatibility /// Split direction for backwards compatibility with old API diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 3f61f26b..f276f2a6 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -3486,6 +3486,143 @@ class TerminalController { return "OK \(base64)" } + private struct PasteboardItemSnapshot { + let representations: [(type: NSPasteboard.PasteboardType, data: Data)] + } + + nonisolated static func normalizedExportedScreenPath(_ raw: String?) -> String? { + guard let raw else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if let url = URL(string: trimmed), + url.isFileURL, + !url.path.isEmpty { + return url.path + } + return trimmed.hasPrefix("/") ? trimmed : nil + } + + nonisolated static func shouldRemoveExportedScreenDirectory( + fileURL: URL, + temporaryDirectory: URL = FileManager.default.temporaryDirectory + ) -> Bool { + let directory = fileURL.deletingLastPathComponent().standardizedFileURL + let temporary = temporaryDirectory.standardizedFileURL + return directory.path.hasPrefix(temporary.path + "/") + } + + private func snapshotPasteboardItems(_ pasteboard: NSPasteboard) -> [PasteboardItemSnapshot] { + guard let items = pasteboard.pasteboardItems else { return [] } + return items.map { item in + let representations = item.types.compactMap { type -> (type: NSPasteboard.PasteboardType, data: Data)? in + guard let data = item.data(forType: type) else { return nil } + return (type: type, data: data) + } + return PasteboardItemSnapshot(representations: representations) + } + } + + private func restorePasteboardItems( + _ snapshots: [PasteboardItemSnapshot], + to pasteboard: NSPasteboard + ) { + _ = pasteboard.clearContents() + guard !snapshots.isEmpty else { return } + + let restoredItems = snapshots.compactMap { snapshot -> NSPasteboardItem? in + guard !snapshot.representations.isEmpty else { return nil } + let item = NSPasteboardItem() + for representation in snapshot.representations { + item.setData(representation.data, forType: representation.type) + } + return item + } + guard !restoredItems.isEmpty else { return } + _ = pasteboard.writeObjects(restoredItems) + } + + private func readGeneralPasteboardString(_ pasteboard: NSPasteboard) -> String? { + if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL], + let firstURL = urls.first, + firstURL.isFileURL { + return firstURL.path + } + if let value = pasteboard.string(forType: .string) { + return value + } + return pasteboard.string(forType: NSPasteboard.PasteboardType("public.utf8-plain-text")) + } + + private func readTerminalTextFromVTExportForSnapshot( + terminalPanel: TerminalPanel, + lineLimit: Int? + ) -> String? { + // read_text strips style state; VT export keeps ANSI escape sequences. + let pasteboard = NSPasteboard.general + let snapshot = snapshotPasteboardItems(pasteboard) + defer { + restorePasteboardItems(snapshot, to: pasteboard) + } + + let initialChangeCount = pasteboard.changeCount + guard terminalPanel.performBindingAction("write_screen_file:copy,vt") else { + return nil + } + guard pasteboard.changeCount != initialChangeCount else { + return nil + } + guard let exportedPath = Self.normalizedExportedScreenPath(readGeneralPasteboardString(pasteboard)) else { + return nil + } + + let fileURL = URL(fileURLWithPath: exportedPath) + defer { + try? FileManager.default.removeItem(at: fileURL) + if Self.shouldRemoveExportedScreenDirectory(fileURL: fileURL) { + try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) + } + } + + guard let data = try? Data(contentsOf: fileURL), + var output = String(data: data, encoding: .utf8) else { + return nil + } + if let lineLimit { + output = tailTerminalLines(output, maxLines: lineLimit) + } + return output + } + + func readTerminalTextForSnapshot( + terminalPanel: TerminalPanel, + includeScrollback: Bool = false, + lineLimit: Int? = nil + ) -> String? { + if includeScrollback, + let vtOutput = readTerminalTextFromVTExportForSnapshot( + terminalPanel: terminalPanel, + lineLimit: lineLimit + ) { + return vtOutput + } + + let response = readTerminalTextBase64( + terminalPanel: terminalPanel, + includeScrollback: includeScrollback, + lineLimit: lineLimit + ) + guard response.hasPrefix("OK ") else { return nil } + let base64 = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines) + if base64.isEmpty { + return "" + } + guard let data = Data(base64Encoded: base64), + let decoded = String(data: data, encoding: .utf8) else { + return nil + } + return decoded + } + private func v2SurfaceTriggerFlash(params: [String: Any]) -> V2CallResult { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) diff --git a/Sources/Update/UpdateDelegate.swift b/Sources/Update/UpdateDelegate.swift index 32e2304c..dfcd457c 100644 --- a/Sources/Update/UpdateDelegate.swift +++ b/Sources/Update/UpdateDelegate.swift @@ -80,7 +80,9 @@ extension UpdateDriver: SPUUpdaterDelegate { } } + @MainActor func updaterWillRelaunchApplication(_ updater: SPUUpdater) { + AppDelegate.shared?.persistSessionForUpdateRelaunch() TerminalController.shared.stop() NSApp.invalidateRestorableState() for window in NSApp.windows { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 547cd84b..e0180270 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -12,6 +12,487 @@ struct SidebarStatusEntry { let timestamp: Date } +private struct SessionPaneRestoreEntry { + let paneId: PaneID + let snapshot: SessionPaneLayoutSnapshot +} + +extension Workspace { + func sessionSnapshot(includeScrollback: Bool) -> SessionWorkspaceSnapshot { + let tree = bonsplitController.treeSnapshot() + let layout = sessionLayoutSnapshot(from: tree) + + let orderedPanelIds = sidebarOrderedPanelIds() + var seen: Set = [] + var allPanelIds: [UUID] = [] + for panelId in orderedPanelIds where seen.insert(panelId).inserted { + allPanelIds.append(panelId) + } + for panelId in panels.keys.sorted(by: { $0.uuidString < $1.uuidString }) where seen.insert(panelId).inserted { + allPanelIds.append(panelId) + } + + let panelSnapshots = allPanelIds + .prefix(SessionPersistencePolicy.maxPanelsPerWorkspace) + .compactMap { sessionPanelSnapshot(panelId: $0, includeScrollback: includeScrollback) } + + let statusSnapshots = statusEntries.values + .sorted { lhs, rhs in lhs.key < rhs.key } + .map { entry in + SessionStatusEntrySnapshot( + key: entry.key, + value: entry.value, + icon: entry.icon, + color: entry.color, + timestamp: entry.timestamp.timeIntervalSince1970 + ) + } + let logSnapshots = logEntries.map { entry in + SessionLogEntrySnapshot( + message: entry.message, + level: entry.level.rawValue, + source: entry.source, + timestamp: entry.timestamp.timeIntervalSince1970 + ) + } + + let progressSnapshot = progress.map { progress in + SessionProgressSnapshot(value: progress.value, label: progress.label) + } + let gitBranchSnapshot = gitBranch.map { branch in + SessionGitBranchSnapshot(branch: branch.branch, isDirty: branch.isDirty) + } + + return SessionWorkspaceSnapshot( + processTitle: processTitle, + customTitle: customTitle, + isPinned: isPinned, + currentDirectory: currentDirectory, + focusedPanelId: focusedPanelId, + layout: layout, + panels: panelSnapshots, + statusEntries: statusSnapshots, + logEntries: logSnapshots, + progress: progressSnapshot, + gitBranch: gitBranchSnapshot + ) + } + + func restoreSessionSnapshot(_ snapshot: SessionWorkspaceSnapshot) { + restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false) + + let normalizedCurrentDirectory = snapshot.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + if !normalizedCurrentDirectory.isEmpty { + currentDirectory = normalizedCurrentDirectory + } + + let panelSnapshotsById = Dictionary(uniqueKeysWithValues: snapshot.panels.map { ($0.id, $0) }) + let leafEntries = restoreSessionLayout(snapshot.layout) + var oldToNewPanelIds: [UUID: UUID] = [:] + + for entry in leafEntries { + restorePane( + entry.paneId, + snapshot: entry.snapshot, + panelSnapshotsById: panelSnapshotsById, + oldToNewPanelIds: &oldToNewPanelIds + ) + } + + pruneSurfaceMetadata(validSurfaceIds: Set(panels.keys)) + applySessionDividerPositions(snapshotNode: snapshot.layout, liveNode: bonsplitController.treeSnapshot()) + + applyProcessTitle(snapshot.processTitle) + setCustomTitle(snapshot.customTitle) + isPinned = snapshot.isPinned + + statusEntries = Dictionary( + uniqueKeysWithValues: snapshot.statusEntries.map { entry in + ( + entry.key, + SidebarStatusEntry( + key: entry.key, + value: entry.value, + icon: entry.icon, + color: entry.color, + timestamp: Date(timeIntervalSince1970: entry.timestamp) + ) + ) + } + ) + logEntries = snapshot.logEntries.map { entry in + SidebarLogEntry( + message: entry.message, + level: SidebarLogLevel(rawValue: entry.level) ?? .info, + source: entry.source, + timestamp: Date(timeIntervalSince1970: entry.timestamp) + ) + } + progress = snapshot.progress.map { SidebarProgressState(value: $0.value, label: $0.label) } + gitBranch = snapshot.gitBranch.map { SidebarGitBranchState(branch: $0.branch, isDirty: $0.isDirty) } + + recomputeListeningPorts() + + if let focusedOldPanelId = snapshot.focusedPanelId, + let focusedNewPanelId = oldToNewPanelIds[focusedOldPanelId], + panels[focusedNewPanelId] != nil { + focusPanel(focusedNewPanelId) + } else if let fallbackFocusedPanelId = focusedPanelId, panels[fallbackFocusedPanelId] != nil { + focusPanel(fallbackFocusedPanelId) + } else { + scheduleFocusReconcile() + } + } + + private func sessionLayoutSnapshot(from node: ExternalTreeNode) -> SessionWorkspaceLayoutSnapshot { + switch node { + case .pane(let pane): + let panelIds = sessionPanelIDs(for: pane) + let selectedPanelId = pane.selectedTabId.flatMap(sessionPanelID(forExternalTabIDString:)) + return .pane( + SessionPaneLayoutSnapshot( + panelIds: panelIds, + selectedPanelId: selectedPanelId + ) + ) + case .split(let split): + return .split( + SessionSplitLayoutSnapshot( + orientation: split.orientation.lowercased() == "vertical" ? .vertical : .horizontal, + dividerPosition: split.dividerPosition, + first: sessionLayoutSnapshot(from: split.first), + second: sessionLayoutSnapshot(from: split.second) + ) + ) + } + } + + private func sessionPanelIDs(for pane: ExternalPaneNode) -> [UUID] { + var panelIds: [UUID] = [] + var seen = Set() + for tab in pane.tabs { + guard let panelId = sessionPanelID(forExternalTabIDString: tab.id) else { continue } + if seen.insert(panelId).inserted { + panelIds.append(panelId) + } + } + return panelIds + } + + private func sessionPanelID(forExternalTabIDString tabIDString: String) -> UUID? { + guard let tabUUID = UUID(uuidString: tabIDString) else { return nil } + for (surfaceId, panelId) in surfaceIdToPanelId { + guard let surfaceUUID = sessionSurfaceUUID(for: surfaceId) else { continue } + if surfaceUUID == tabUUID { + return panelId + } + } + return nil + } + + private func sessionSurfaceUUID(for surfaceId: TabID) -> UUID? { + struct EncodedSurfaceID: Decodable { + let id: UUID + } + + guard let data = try? JSONEncoder().encode(surfaceId), + let decoded = try? JSONDecoder().decode(EncodedSurfaceID.self, from: data) else { + return nil + } + return decoded.id + } + + private func sessionPanelSnapshot(panelId: UUID, includeScrollback: Bool) -> SessionPanelSnapshot? { + guard let panel = panels[panelId] else { return nil } + + let panelTitle = panelTitle(panelId: panelId) + let customTitle = panelCustomTitles[panelId] + let directory = panelDirectories[panelId] + let isPinned = pinnedPanelIds.contains(panelId) + let isManuallyUnread = manualUnreadPanelIds.contains(panelId) + let branchSnapshot = panelGitBranches[panelId].map { + SessionGitBranchSnapshot(branch: $0.branch, isDirty: $0.isDirty) + } + let listeningPorts = (surfaceListeningPorts[panelId] ?? []).sorted() + let ttyName = surfaceTTYNames[panelId] + + let terminalSnapshot: SessionTerminalPanelSnapshot? + let browserSnapshot: SessionBrowserPanelSnapshot? + switch panel.panelType { + case .terminal: + guard let terminalPanel = panel as? TerminalPanel else { return nil } + let capturedScrollback = includeScrollback + ? TerminalController.shared.readTerminalTextForSnapshot( + terminalPanel: terminalPanel, + includeScrollback: true, + lineLimit: SessionPersistencePolicy.maxScrollbackLinesPerTerminal + ) + : nil + let resolvedScrollback = terminalSnapshotScrollback( + panelId: panelId, + capturedScrollback: capturedScrollback, + includeScrollback: includeScrollback + ) + terminalSnapshot = SessionTerminalPanelSnapshot( + workingDirectory: panelDirectories[panelId], + scrollback: resolvedScrollback + ) + browserSnapshot = nil + case .browser: + guard let browserPanel = panel as? BrowserPanel else { return nil } + terminalSnapshot = nil + browserSnapshot = SessionBrowserPanelSnapshot( + urlString: browserPanel.currentURL?.absoluteString, + shouldRenderWebView: browserPanel.shouldRenderWebView, + pageZoom: Double(browserPanel.webView.pageZoom), + developerToolsVisible: browserPanel.isDeveloperToolsVisible() + ) + } + + return SessionPanelSnapshot( + id: panelId, + type: panel.panelType, + title: panelTitle, + customTitle: customTitle, + directory: directory, + isPinned: isPinned, + isManuallyUnread: isManuallyUnread, + gitBranch: branchSnapshot, + listeningPorts: listeningPorts, + ttyName: ttyName, + terminal: terminalSnapshot, + browser: browserSnapshot + ) + } + + nonisolated static func resolvedSnapshotTerminalScrollback( + capturedScrollback: String?, + fallbackScrollback: String? + ) -> String? { + if let captured = SessionPersistencePolicy.truncatedScrollback(capturedScrollback) { + return captured + } + return SessionPersistencePolicy.truncatedScrollback(fallbackScrollback) + } + + private func terminalSnapshotScrollback( + panelId: UUID, + capturedScrollback: String?, + includeScrollback: Bool + ) -> String? { + guard includeScrollback else { return nil } + let fallback = restoredTerminalScrollbackByPanelId[panelId] + let resolved = Self.resolvedSnapshotTerminalScrollback( + capturedScrollback: capturedScrollback, + fallbackScrollback: fallback + ) + if let resolved { + restoredTerminalScrollbackByPanelId[panelId] = resolved + } else { + restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) + } + return resolved + } + + private func restoreSessionLayout(_ layout: SessionWorkspaceLayoutSnapshot) -> [SessionPaneRestoreEntry] { + guard let rootPaneId = bonsplitController.allPaneIds.first else { + return [] + } + + var leaves: [SessionPaneRestoreEntry] = [] + restoreSessionLayoutNode(layout, inPane: rootPaneId, leaves: &leaves) + return leaves + } + + private func restoreSessionLayoutNode( + _ node: SessionWorkspaceLayoutSnapshot, + inPane paneId: PaneID, + leaves: inout [SessionPaneRestoreEntry] + ) { + switch node { + case .pane(let pane): + leaves.append(SessionPaneRestoreEntry(paneId: paneId, snapshot: pane)) + case .split(let split): + var anchorPanelId = bonsplitController + .tabs(inPane: paneId) + .compactMap { panelIdFromSurfaceId($0.id) } + .first + + if anchorPanelId == nil { + anchorPanelId = newTerminalSurface(inPane: paneId, focus: false)?.id + } + + guard let anchorPanelId, + let newSplitPanel = newTerminalSplit( + from: anchorPanelId, + orientation: split.orientation.splitOrientation, + insertFirst: false, + focus: false + ), + let secondPaneId = self.paneId(forPanelId: newSplitPanel.id) else { + leaves.append( + SessionPaneRestoreEntry( + paneId: paneId, + snapshot: SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil) + ) + ) + return + } + + restoreSessionLayoutNode(split.first, inPane: paneId, leaves: &leaves) + restoreSessionLayoutNode(split.second, inPane: secondPaneId, leaves: &leaves) + } + } + + private func restorePane( + _ paneId: PaneID, + snapshot: SessionPaneLayoutSnapshot, + panelSnapshotsById: [UUID: SessionPanelSnapshot], + oldToNewPanelIds: inout [UUID: UUID] + ) { + let existingPanelIds = bonsplitController + .tabs(inPane: paneId) + .compactMap { panelIdFromSurfaceId($0.id) } + let desiredOldPanelIds = snapshot.panelIds.filter { panelSnapshotsById[$0] != nil } + + var createdPanelIds: [UUID] = [] + for oldPanelId in desiredOldPanelIds { + guard let panelSnapshot = panelSnapshotsById[oldPanelId] else { continue } + guard let createdPanelId = createPanel(from: panelSnapshot, inPane: paneId) else { continue } + createdPanelIds.append(createdPanelId) + oldToNewPanelIds[oldPanelId] = createdPanelId + } + + guard !createdPanelIds.isEmpty else { return } + + for oldPanelId in existingPanelIds where !createdPanelIds.contains(oldPanelId) { + _ = closePanel(oldPanelId, force: true) + } + + for (index, panelId) in createdPanelIds.enumerated() { + _ = reorderSurface(panelId: panelId, toIndex: index) + } + + let selectedPanelId: UUID? = { + if let selectedOldId = snapshot.selectedPanelId { + return oldToNewPanelIds[selectedOldId] + } + return createdPanelIds.first + }() + + if let selectedPanelId, + let selectedTabId = surfaceIdFromPanelId(selectedPanelId) { + bonsplitController.focusPane(paneId) + bonsplitController.selectTab(selectedTabId) + } + } + + private func createPanel(from snapshot: SessionPanelSnapshot, inPane paneId: PaneID) -> UUID? { + switch snapshot.type { + case .terminal: + let workingDirectory = snapshot.terminal?.workingDirectory ?? snapshot.directory ?? currentDirectory + let replayEnvironment = SessionScrollbackReplayStore.replayEnvironment( + for: snapshot.terminal?.scrollback + ) + guard let terminalPanel = newTerminalSurface( + inPane: paneId, + focus: false, + workingDirectory: workingDirectory, + startupEnvironment: replayEnvironment + ) else { + return nil + } + let fallbackScrollback = SessionPersistencePolicy.truncatedScrollback(snapshot.terminal?.scrollback) + if let fallbackScrollback { + restoredTerminalScrollbackByPanelId[terminalPanel.id] = fallbackScrollback + } else { + restoredTerminalScrollbackByPanelId.removeValue(forKey: terminalPanel.id) + } + applySessionPanelMetadata(snapshot, toPanelId: terminalPanel.id) + return terminalPanel.id + case .browser: + let initialURL = snapshot.browser?.urlString.flatMap { URL(string: $0) } + guard let browserPanel = newBrowserSurface( + inPane: paneId, + url: initialURL, + focus: false + ) else { + return nil + } + applySessionPanelMetadata(snapshot, toPanelId: browserPanel.id) + return browserPanel.id + } + } + + private func applySessionPanelMetadata(_ snapshot: SessionPanelSnapshot, toPanelId panelId: UUID) { + if let title = snapshot.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty { + panelTitles[panelId] = title + } + + setPanelCustomTitle(panelId: panelId, title: snapshot.customTitle) + setPanelPinned(panelId: panelId, pinned: snapshot.isPinned) + + if snapshot.isManuallyUnread { + markPanelUnread(panelId) + } else { + clearManualUnread(panelId: panelId) + } + + if let directory = snapshot.directory?.trimmingCharacters(in: .whitespacesAndNewlines), !directory.isEmpty { + updatePanelDirectory(panelId: panelId, directory: directory) + } + + if let branch = snapshot.gitBranch { + panelGitBranches[panelId] = SidebarGitBranchState(branch: branch.branch, isDirty: branch.isDirty) + } else { + panelGitBranches.removeValue(forKey: panelId) + } + + surfaceListeningPorts[panelId] = Array(Set(snapshot.listeningPorts)).sorted() + + if let ttyName = snapshot.ttyName?.trimmingCharacters(in: .whitespacesAndNewlines), !ttyName.isEmpty { + surfaceTTYNames[panelId] = ttyName + } else { + surfaceTTYNames.removeValue(forKey: panelId) + } + + if let browserSnapshot = snapshot.browser, + let browserPanel = browserPanel(for: panelId) { + let pageZoom = CGFloat(max(0.25, min(5.0, browserSnapshot.pageZoom))) + if pageZoom.isFinite { + browserPanel.webView.pageZoom = pageZoom + } + + if browserSnapshot.developerToolsVisible { + _ = browserPanel.showDeveloperTools() + browserPanel.requestDeveloperToolsRefreshAfterNextAttach(reason: "session_restore") + } else { + _ = browserPanel.hideDeveloperTools() + } + } + } + + private func applySessionDividerPositions( + snapshotNode: SessionWorkspaceLayoutSnapshot, + liveNode: ExternalTreeNode + ) { + switch (snapshotNode, liveNode) { + case (.split(let snapshotSplit), .split(let liveSplit)): + if let splitID = UUID(uuidString: liveSplit.id) { + _ = bonsplitController.setDividerPosition( + CGFloat(snapshotSplit.dividerPosition), + forSplit: splitID, + fromExternal: true + ) + } + applySessionDividerPositions(snapshotNode: snapshotSplit.first, liveNode: liveSplit.first) + applySessionDividerPositions(snapshotNode: snapshotSplit.second, liveNode: liveSplit.second) + default: + return + } + } +} + enum SidebarLogLevel: String { case info case progress @@ -302,6 +783,7 @@ final class Workspace: Identifiable, ObservableObject { @Published var surfaceListeningPorts: [UUID: [Int]] = [:] @Published var listeningPorts: [Int] = [] var surfaceTTYNames: [UUID: String] = [:] + private var restoredTerminalScrollbackByPanelId: [UUID: String] = [:] var focusedSurfaceId: UUID? { focusedPanelId } var surfaceDirectories: [UUID: String] { @@ -995,7 +1477,12 @@ final class Workspace: Identifiable, ObservableObject { /// true = force focus/selection of the new surface, /// false = never focus (used for internal placeholder repair paths). @discardableResult - func newTerminalSurface(inPane paneId: PaneID, focus: Bool? = nil) -> TerminalPanel? { + func newTerminalSurface( + inPane paneId: PaneID, + focus: Bool? = nil, + workingDirectory: String? = nil, + startupEnvironment: [String: String] = [:] + ) -> TerminalPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) // Get an existing terminal panel to inherit config from @@ -1014,6 +1501,8 @@ final class Workspace: Identifiable, ObservableObject { workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: inheritedConfig, + workingDirectory: workingDirectory, + additionalEnvironment: startupEnvironment, portOrdinal: portOrdinal ) panels[newPanel.id] = newPanel @@ -2158,6 +2647,7 @@ extension Workspace: BonsplitDelegate { manualUnreadMarkedAt.removeValue(forKey: panelId) panelSubscriptions.removeValue(forKey: panelId) surfaceTTYNames.removeValue(forKey: panelId) + restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) // Keep the workspace invariant: always retain at least one real panel. @@ -2250,6 +2740,7 @@ extension Workspace: BonsplitDelegate { panelSubscriptions.removeValue(forKey: panelId) surfaceTTYNames.removeValue(forKey: panelId) surfaceListeningPorts.removeValue(forKey: panelId) + restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 09b18c59..cdbc0175 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -343,7 +343,18 @@ struct cmuxApp: App { .keyboardShortcut("n", modifiers: [.command, .shift]) Button("New Workspace") { - (AppDelegate.shared?.tabManager ?? tabManager).addTab() + if let appDelegate = AppDelegate.shared { + if appDelegate.addWorkspaceInPreferredMainWindow(debugSource: "menu.newWorkspace") == nil { +#if DEBUG + FocusLogStore.shared.append( + "cmdn.route phase=fallback_new_window src=menu.newWorkspace reason=workspace_creation_returned_nil" + ) +#endif + appDelegate.openNewMainWindow(nil) + } + } else { + tabManager.addTab() + } } } diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift new file mode 100644 index 00000000..dae9faff --- /dev/null +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -0,0 +1,214 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class AppDelegateShortcutRoutingTests: XCTestCase { + func testCmdNUsesEventWindowContextWhenActiveManagerIsStale() { + 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 + } + + let firstCount = firstManager.tabs.count + let secondCount = secondManager.tabs.count + + XCTAssertTrue(appDelegate.focusMainWindow(windowId: firstWindowId)) + + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: secondWindow.windowNumber, + context: nil, + characters: "n", + charactersIgnoringModifiers: "n", + isARepeat: false, + keyCode: 45 + ) else { + XCTFail("Failed to construct Cmd+N event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + XCTAssertEqual(firstManager.tabs.count, firstCount, "Cmd+N should not add workspace to stale active window") + XCTAssertEqual(secondManager.tabs.count, secondCount + 1, "Cmd+N should add workspace to the event's window") + } + + func testAddWorkspaceInPreferredMainWindowIgnoresStaleTabManagerPointer() { + 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 + } + + let firstCount = firstManager.tabs.count + let secondCount = secondManager.tabs.count + + secondWindow.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + // Force a stale app-level pointer to a different manager. + appDelegate.tabManager = firstManager + XCTAssertTrue(appDelegate.tabManager === firstManager) + + _ = appDelegate.addWorkspaceInPreferredMainWindow() + + XCTAssertEqual(firstManager.tabs.count, firstCount, "Stale pointer must not receive menu-driven workspace creation") + XCTAssertEqual(secondManager.tabs.count, secondCount + 1, "Workspace creation should target key/main window context") + } + + func testCmdNResolvesEventWindowWhenObjectKeyLookupIsMismatched() { + 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)) + +#if DEBUG + XCTAssertTrue(appDelegate.debugInjectWindowContextKeyMismatch(windowId: secondWindowId)) +#else + XCTFail("debugInjectWindowContextKeyMismatch is only available in DEBUG") +#endif + + // Ensure stale active-manager pointer does not mask routing errors. + appDelegate.tabManager = firstManager + + let firstCount = firstManager.tabs.count + let secondCount = secondManager.tabs.count + + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: secondWindow.windowNumber, + context: nil, + characters: "n", + charactersIgnoringModifiers: "n", + isARepeat: false, + keyCode: 45 + ) else { + XCTFail("Failed to construct Cmd+N event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + XCTAssertEqual(firstManager.tabs.count, firstCount, "Cmd+N should not route to another window when object-key lookup misses") + XCTAssertEqual(secondManager.tabs.count, secondCount + 1, "Cmd+N should still route by event window metadata when object-key lookup misses") + } + + func testAddWorkspaceInPreferredMainWindowUsesKeyWindowWhenObjectKeyLookupIsMismatched() { + 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)) + +#if DEBUG + XCTAssertTrue(appDelegate.debugInjectWindowContextKeyMismatch(windowId: secondWindowId)) +#else + XCTFail("debugInjectWindowContextKeyMismatch is only available in DEBUG") +#endif + + // Stale pointer should not receive the new workspace. + appDelegate.tabManager = firstManager + + let firstCount = firstManager.tabs.count + let secondCount = secondManager.tabs.count + + _ = appDelegate.addWorkspaceInPreferredMainWindow() + + XCTAssertEqual(firstManager.tabs.count, firstCount, "Menu-driven add workspace should not route to stale window") + XCTAssertEqual(secondManager.tabs.count, secondCount + 1, "Menu-driven add workspace should still route to key window context when object-key lookup misses") + } + + private func window(withId windowId: UUID) -> NSWindow? { + let identifier = "cmux.main.\(windowId.uuidString)" + return NSApp.windows.first(where: { $0.identifier?.rawValue == identifier }) + } + + private func closeWindow(withId windowId: UUID) { + guard let window = window(withId: windowId) else { return } + window.performClose(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + } +} diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift new file mode 100644 index 00000000..fd2fff77 --- /dev/null +++ b/cmuxTests/SessionPersistenceTests.swift @@ -0,0 +1,382 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class SessionPersistenceTests: XCTestCase { + func testSaveAndLoadRoundTripWithCustomSnapshotPath() { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let snapshotURL = tempDir.appendingPathComponent("session.json", isDirectory: false) + let snapshot = makeSnapshot(version: SessionSnapshotSchema.currentVersion) + + XCTAssertTrue(SessionPersistenceStore.save(snapshot, fileURL: snapshotURL)) + + let loaded = SessionPersistenceStore.load(fileURL: snapshotURL) + XCTAssertNotNil(loaded) + XCTAssertEqual(loaded?.version, SessionSnapshotSchema.currentVersion) + XCTAssertEqual(loaded?.windows.count, 1) + XCTAssertEqual(loaded?.windows.first?.sidebar.selection, .tabs) + } + + func testLoadRejectsSchemaVersionMismatch() { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let snapshotURL = tempDir.appendingPathComponent("session.json", isDirectory: false) + XCTAssertTrue(SessionPersistenceStore.save(makeSnapshot(version: SessionSnapshotSchema.currentVersion + 1), fileURL: snapshotURL)) + + XCTAssertNil(SessionPersistenceStore.load(fileURL: snapshotURL)) + } + + func testDefaultSnapshotPathSanitizesBundleIdentifier() { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let path = SessionPersistenceStore.defaultSnapshotFileURL( + bundleIdentifier: "com.example/unsafe id", + appSupportDirectory: tempDir + ) + + XCTAssertNotNil(path) + XCTAssertTrue(path?.path.contains("com.example_unsafe_id") == true) + } + + func testRestorePolicySkipsWhenLaunchHasExplicitArguments() { + let shouldRestore = SessionRestorePolicy.shouldAttemptRestore( + arguments: ["/Applications/cmux.app/Contents/MacOS/cmux", "--window", "window:1"], + environment: [:] + ) + + XCTAssertFalse(shouldRestore) + } + + func testRestorePolicyAllowsFinderStyleLaunchArgumentsOnly() { + let shouldRestore = SessionRestorePolicy.shouldAttemptRestore( + arguments: ["/Applications/cmux.app/Contents/MacOS/cmux", "-psn_0_12345"], + environment: [:] + ) + + XCTAssertTrue(shouldRestore) + } + + func testRestorePolicySkipsWhenRunningUnderXCTest() { + let shouldRestore = SessionRestorePolicy.shouldAttemptRestore( + arguments: ["/Applications/cmux.app/Contents/MacOS/cmux"], + environment: ["XCTestConfigurationFilePath": "/tmp/xctest.xctestconfiguration"] + ) + + XCTAssertFalse(shouldRestore) + } + + func testSidebarWidthSanitizationClampsToPolicyRange() { + XCTAssertEqual( + SessionPersistencePolicy.sanitizedSidebarWidth(-20), + SessionPersistencePolicy.minimumSidebarWidth, + accuracy: 0.001 + ) + XCTAssertEqual( + SessionPersistencePolicy.sanitizedSidebarWidth(10_000), + SessionPersistencePolicy.maximumSidebarWidth, + accuracy: 0.001 + ) + XCTAssertEqual( + SessionPersistencePolicy.sanitizedSidebarWidth(nil), + SessionPersistencePolicy.defaultSidebarWidth, + accuracy: 0.001 + ) + } + + func testScrollbackReplayEnvironmentWritesReplayFile() { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-scrollback-replay-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let environment = SessionScrollbackReplayStore.replayEnvironment( + for: "line one\nline two\n", + tempDirectory: tempDir + ) + + let path = environment[SessionScrollbackReplayStore.environmentKey] + XCTAssertNotNil(path) + XCTAssertTrue(path?.hasPrefix(tempDir.path) == true) + + guard let path else { return } + let contents = try? String(contentsOfFile: path, encoding: .utf8) + XCTAssertEqual(contents, "line one\nline two\n") + } + + func testScrollbackReplayEnvironmentSkipsWhitespaceOnlyContent() { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-scrollback-replay-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let environment = SessionScrollbackReplayStore.replayEnvironment( + for: " \n\t ", + tempDirectory: tempDir + ) + + XCTAssertTrue(environment.isEmpty) + } + + func testScrollbackReplayEnvironmentPreservesANSIColorSequences() { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-scrollback-replay-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let red = "\u{001B}[31m" + let reset = "\u{001B}[0m" + let source = "\(red)RED\(reset)\n" + let environment = SessionScrollbackReplayStore.replayEnvironment( + for: source, + tempDirectory: tempDir + ) + + guard let path = environment[SessionScrollbackReplayStore.environmentKey] else { + XCTFail("Expected replay file path") + return + } + + guard let contents = try? String(contentsOfFile: path, encoding: .utf8) else { + XCTFail("Expected replay file contents") + return + } + + XCTAssertTrue(contents.contains("\(red)RED\(reset)")) + XCTAssertTrue(contents.hasPrefix(reset)) + XCTAssertTrue(contents.hasSuffix(reset)) + } + + func testTruncatedScrollbackAvoidsLeadingPartialANSICSISequence() { + let maxChars = SessionPersistencePolicy.maxScrollbackCharactersPerTerminal + let source = "\u{001B}[31m" + + String(repeating: "X", count: maxChars - 7) + + "\u{001B}[0m" + + guard let truncated = SessionPersistencePolicy.truncatedScrollback(source) else { + XCTFail("Expected truncated scrollback") + return + } + + XCTAssertFalse(truncated.hasPrefix("31m")) + XCTAssertFalse(truncated.hasPrefix("[31m")) + XCTAssertFalse(truncated.hasPrefix("m")) + } + + func testNormalizedExportedScreenPathAcceptsAbsoluteAndFileURL() { + XCTAssertEqual( + TerminalController.normalizedExportedScreenPath("/tmp/cmux-screen.txt"), + "/tmp/cmux-screen.txt" + ) + XCTAssertEqual( + TerminalController.normalizedExportedScreenPath(" file:///tmp/cmux-screen.txt "), + "/tmp/cmux-screen.txt" + ) + } + + func testNormalizedExportedScreenPathRejectsRelativeAndWhitespace() { + XCTAssertNil(TerminalController.normalizedExportedScreenPath("relative/path.txt")) + XCTAssertNil(TerminalController.normalizedExportedScreenPath(" ")) + XCTAssertNil(TerminalController.normalizedExportedScreenPath(nil)) + } + + func testShouldRemoveExportedScreenDirectoryOnlyWithinTemporaryRoot() { + let tempRoot = URL(fileURLWithPath: "/tmp") + .appendingPathComponent("cmux-export-tests-\(UUID().uuidString)", isDirectory: true) + let tempFile = tempRoot + .appendingPathComponent(UUID().uuidString, isDirectory: true) + .appendingPathComponent("screen.txt", isDirectory: false) + let outsideFile = URL(fileURLWithPath: "/Users/example/screen.txt") + + XCTAssertTrue( + TerminalController.shouldRemoveExportedScreenDirectory( + fileURL: tempFile, + temporaryDirectory: tempRoot + ) + ) + XCTAssertFalse( + TerminalController.shouldRemoveExportedScreenDirectory( + fileURL: outsideFile, + temporaryDirectory: tempRoot + ) + ) + } + + func testWindowUnregisterSnapshotPersistencePolicy() { + XCTAssertTrue( + AppDelegate.shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: false) + ) + XCTAssertFalse( + AppDelegate.shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: true) + ) + } + + func testResolvedWindowFramePrefersSavedDisplayIdentity() { + let savedFrame = SessionRectSnapshot(x: 1_200, y: 100, width: 600, height: 400) + let savedDisplay = SessionDisplaySnapshot( + displayID: 2, + frame: SessionRectSnapshot(x: 1_000, y: 0, width: 1_000, height: 800), + visibleFrame: SessionRectSnapshot(x: 1_000, y: 0, width: 1_000, height: 800) + ) + + // Display 1 and 2 swapped horizontal positions between snapshot and restore. + let display1 = AppDelegate.SessionDisplayGeometry( + displayID: 1, + frame: CGRect(x: 1_000, y: 0, width: 1_000, height: 800), + visibleFrame: CGRect(x: 1_000, y: 0, width: 1_000, height: 800) + ) + let display2 = AppDelegate.SessionDisplayGeometry( + displayID: 2, + frame: CGRect(x: 0, y: 0, width: 1_000, height: 800), + visibleFrame: CGRect(x: 0, y: 0, width: 1_000, height: 800) + ) + + let restored = AppDelegate.resolvedWindowFrame( + from: savedFrame, + display: savedDisplay, + availableDisplays: [display1, display2], + fallbackDisplay: display1 + ) + + XCTAssertNotNil(restored) + guard let restored else { return } + XCTAssertTrue(display2.visibleFrame.intersects(restored)) + XCTAssertFalse(display1.visibleFrame.intersects(restored)) + XCTAssertEqual(restored.width, 600, accuracy: 0.001) + XCTAssertEqual(restored.height, 400, accuracy: 0.001) + XCTAssertEqual(restored.minX, 200, accuracy: 0.001) + XCTAssertEqual(restored.minY, 100, accuracy: 0.001) + } + + func testResolvedWindowFrameKeepsIntersectingFrameWithoutDisplayMetadata() { + let savedFrame = SessionRectSnapshot(x: 120, y: 80, width: 500, height: 350) + let display = AppDelegate.SessionDisplayGeometry( + displayID: 1, + frame: CGRect(x: 0, y: 0, width: 1_000, height: 800), + visibleFrame: CGRect(x: 0, y: 0, width: 1_000, height: 800) + ) + + let restored = AppDelegate.resolvedWindowFrame( + from: savedFrame, + display: nil, + availableDisplays: [display], + fallbackDisplay: display + ) + + XCTAssertNotNil(restored) + guard let restored else { return } + XCTAssertEqual(restored.minX, 120, accuracy: 0.001) + XCTAssertEqual(restored.minY, 80, accuracy: 0.001) + XCTAssertEqual(restored.width, 500, accuracy: 0.001) + XCTAssertEqual(restored.height, 350, accuracy: 0.001) + } + + func testResolvedWindowFrameCentersInFallbackDisplayWhenOffscreen() { + let savedFrame = SessionRectSnapshot(x: 4_000, y: 4_000, width: 900, height: 700) + let display = AppDelegate.SessionDisplayGeometry( + displayID: 1, + frame: CGRect(x: 0, y: 0, width: 1_000, height: 800), + visibleFrame: CGRect(x: 0, y: 0, width: 1_000, height: 800) + ) + + let restored = AppDelegate.resolvedWindowFrame( + from: savedFrame, + display: nil, + availableDisplays: [display], + fallbackDisplay: display + ) + + XCTAssertNotNil(restored) + guard let restored else { return } + XCTAssertTrue(display.visibleFrame.contains(restored)) + XCTAssertEqual(restored.minX, 50, accuracy: 0.001) + XCTAssertEqual(restored.minY, 50, accuracy: 0.001) + XCTAssertEqual(restored.width, 900, accuracy: 0.001) + XCTAssertEqual(restored.height, 700, accuracy: 0.001) + } + + func testResolvedSnapshotTerminalScrollbackPrefersCaptured() { + let resolved = Workspace.resolvedSnapshotTerminalScrollback( + capturedScrollback: "captured-value", + fallbackScrollback: "fallback-value" + ) + + XCTAssertEqual(resolved, "captured-value") + } + + func testResolvedSnapshotTerminalScrollbackFallsBackWhenCaptureMissing() { + let resolved = Workspace.resolvedSnapshotTerminalScrollback( + capturedScrollback: nil, + fallbackScrollback: "fallback-value" + ) + + XCTAssertEqual(resolved, "fallback-value") + } + + func testResolvedSnapshotTerminalScrollbackTruncatesFallback() { + let oversizedFallback = String( + repeating: "x", + count: SessionPersistencePolicy.maxScrollbackCharactersPerTerminal + 37 + ) + let resolved = Workspace.resolvedSnapshotTerminalScrollback( + capturedScrollback: nil, + fallbackScrollback: oversizedFallback + ) + + XCTAssertEqual( + resolved?.count, + SessionPersistencePolicy.maxScrollbackCharactersPerTerminal + ) + } + + private func makeSnapshot(version: Int) -> AppSessionSnapshot { + let workspace = SessionWorkspaceSnapshot( + processTitle: "Terminal", + customTitle: "Restored", + isPinned: true, + currentDirectory: "/tmp", + focusedPanelId: nil, + layout: .pane(SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil)), + panels: [], + statusEntries: [], + logEntries: [], + progress: nil, + gitBranch: nil + ) + + let tabManager = SessionTabManagerSnapshot( + selectedWorkspaceIndex: 0, + workspaces: [workspace] + ) + + let window = SessionWindowSnapshot( + frame: SessionRectSnapshot(x: 10, y: 20, width: 900, height: 700), + display: SessionDisplaySnapshot( + displayID: 42, + frame: SessionRectSnapshot(x: 0, y: 0, width: 1920, height: 1200), + visibleFrame: SessionRectSnapshot(x: 0, y: 25, width: 1920, height: 1175) + ), + tabManager: tabManager, + sidebar: SessionSidebarSnapshot(isVisible: true, selection: .tabs, width: 240) + ) + + return AppSessionSnapshot( + version: version, + createdAt: Date().timeIntervalSince1970, + windows: [window] + ) + } +} diff --git a/tests/test_session_restore_unfocused_workspace_multi_window_cycle.py b/tests/test_session_restore_unfocused_workspace_multi_window_cycle.py new file mode 100644 index 00000000..0f63b2b9 --- /dev/null +++ b/tests/test_session_restore_unfocused_workspace_multi_window_cycle.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +""" +Regression: unfocused workspace scrollback must persist across relaunchs in multi-window setups. +""" + +from __future__ import annotations + +import os +import plistlib +import re +import socket +import subprocess +import time +from pathlib import Path + +from cmux import cmux + + +def _bundle_id(app_path: Path) -> str: + info_path = app_path / "Contents" / "Info.plist" + if not info_path.exists(): + raise RuntimeError(f"Missing Info.plist at {info_path}") + with info_path.open("rb") as f: + info = plistlib.load(f) + bundle_id = str(info.get("CFBundleIdentifier", "")).strip() + if not bundle_id: + raise RuntimeError("Missing CFBundleIdentifier") + return bundle_id + + +def _snapshot_path(bundle_id: str) -> Path: + safe_bundle = re.sub(r"[^A-Za-z0-9._-]", "_", bundle_id) + return Path.home() / "Library/Application Support/cmux" / f"session-{safe_bundle}.json" + + +def _sanitize_tag_slug(raw: str) -> str: + cleaned = re.sub(r"[^a-z0-9]+", "-", (raw or "").strip().lower()) + cleaned = re.sub(r"-+", "-", cleaned).strip("-") + return cleaned or "agent" + + +def _socket_candidates(app_path: Path, preferred: Path) -> list[Path]: + candidates = [preferred] + app_name = app_path.stem + prefix = "cmux DEV " + if app_name.startswith(prefix): + tag = app_name[len(prefix):] + slug = _sanitize_tag_slug(tag) + candidates.append(Path(f"/tmp/cmux-debug-{slug}.sock")) + deduped: list[Path] = [] + seen: set[str] = set() + for candidate in candidates: + key = str(candidate) + if key in seen: + continue + seen.add(key) + deduped.append(candidate) + return deduped + + +def _socket_reachable(socket_path: Path) -> bool: + if not socket_path.exists(): + return False + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + sock.settimeout(0.3) + sock.connect(str(socket_path)) + sock.sendall(b"ping\n") + data = sock.recv(1024) + return b"PONG" in data + except OSError: + return False + finally: + sock.close() + + +def _wait_for_socket(candidates: list[Path], timeout: float = 20.0) -> Path: + deadline = time.time() + timeout + while time.time() < deadline: + for candidate in candidates: + if _socket_reachable(candidate): + return candidate + time.sleep(0.2) + joined = ", ".join(str(path) for path in candidates) + raise RuntimeError(f"Socket did not become reachable: {joined}") + + +def _wait_for_socket_closed(socket_path: Path, timeout: float = 20.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + if not _socket_reachable(socket_path): + return + time.sleep(0.2) + raise RuntimeError(f"Socket still reachable after quit: {socket_path}") + + +def _kill_existing(app_path: Path) -> None: + exe = app_path / "Contents" / "MacOS" / "cmux DEV" + subprocess.run(["pkill", "-f", str(exe)], capture_output=True, text=True) + time.sleep(1.0) + + +def _launch(app_path: Path, preferred_socket_path: Path) -> Path: + try: + preferred_socket_path.unlink() + except FileNotFoundError: + pass + subprocess.run( + [ + "open", + "-na", + str(app_path), + "--env", + f"CMUX_SOCKET_PATH={preferred_socket_path}", + "--env", + "CMUX_ALLOW_SOCKET_OVERRIDE=1", + ], + check=True, + ) + resolved_socket_path = _wait_for_socket(_socket_candidates(app_path, preferred_socket_path)) + time.sleep(1.5) + return resolved_socket_path + + +def _quit(bundle_id: str, socket_path: Path) -> None: + subprocess.run( + ["osascript", "-e", f'tell application id "{bundle_id}" to quit'], + capture_output=True, + text=True, + check=True, + ) + _wait_for_socket_closed(socket_path) + try: + socket_path.unlink() + except FileNotFoundError: + pass + time.sleep(0.8) + + +def _connect(socket_path: Path) -> cmux: + client = cmux(socket_path=str(socket_path)) + client.connect() + if not client.ping(): + raise RuntimeError("ping failed") + return client + + +def _read_scrollback(client: cmux) -> str: + return client._send_command("read_screen --scrollback") + + +def _wait_for_marker(client: cmux, marker: str, timeout: float = 8.0) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + if marker in _read_scrollback(client): + return True + time.sleep(0.25) + return False + + +def _consume_visible_markers(client: cmux, remaining: set[str], timeout: float = 4.0) -> None: + if not remaining: + return + deadline = time.time() + timeout + while time.time() < deadline and remaining: + text = _read_scrollback(client) + matched = [marker for marker in remaining if marker in text] + if matched: + for marker in matched: + remaining.discard(marker) + if not remaining: + return + time.sleep(0.25) + + +def _ensure_workspaces(client: cmux, count: int) -> None: + while len(client.list_workspaces()) < count: + client.new_workspace() + time.sleep(0.3) + + +def _list_windows(client: cmux) -> list[str]: + response = client._send_command("list_windows") + if response == "No windows": + return [] + window_ids: list[str] = [] + for line in response.splitlines(): + line = line.strip() + if not line: + continue + parts = line.lstrip("* ").split(" ", 2) + if len(parts) >= 2: + window_ids.append(parts[1]) + return window_ids + + +def _new_window(client: cmux) -> str: + response = client._send_command("new_window") + if not response.startswith("OK "): + raise RuntimeError(f"new_window failed: {response}") + return response.split(" ", 1)[1].strip() + + +def _focus_window(client: cmux, window_id: str) -> None: + response = client._send_command(f"focus_window {window_id}") + if response != "OK": + raise RuntimeError(f"focus_window failed for {window_id}: {response}") + + +def main() -> int: + app_path_str = os.environ.get("CMUX_APP_PATH", "").strip() + if not app_path_str: + print("SKIP: set CMUX_APP_PATH to a built cmux DEV .app path") + return 0 + app_path = Path(app_path_str) + if not app_path.exists(): + print(f"SKIP: CMUX_APP_PATH does not exist: {app_path}") + return 0 + + bundle_id = _bundle_id(app_path) + snapshot = _snapshot_path(bundle_id) + # Keep the override path short enough for Darwin's Unix socket path limit. + bundle_suffix = re.sub(r"[^A-Za-z0-9]", "", bundle_id)[-16:] or "bundle" + socket_path = Path(f"/tmp/cmux-mw-restore-{bundle_suffix}.sock") + + markers = { + "w1_ws0": "CMUX_MW_RESTORE_W1_WS0", + "w1_ws1": "CMUX_MW_RESTORE_W1_WS1", + "w2_ws0": "CMUX_MW_RESTORE_W2_WS0", + "w2_ws1": "CMUX_MW_RESTORE_W2_WS1", + } + failures: list[str] = [] + + _kill_existing(app_path) + snapshot.unlink(missing_ok=True) + + try: + # Launch 1: create 2 windows x 2 workspaces; write markers. + socket_path = _launch(app_path, socket_path) + client = _connect(socket_path) + try: + # Window 1 setup. + _ensure_workspaces(client, 2) + client.select_workspace(0) + client.send(f"echo {markers['w1_ws0']}\n") + if not _wait_for_marker(client, markers["w1_ws0"]): + failures.append("missing marker for window1 workspace0 during setup") + client.select_workspace(1) + client.send(f"echo {markers['w1_ws1']}\n") + if not _wait_for_marker(client, markers["w1_ws1"]): + failures.append("missing marker for window1 workspace1 during setup") + client.select_workspace(0) # leave workspace 1 unfocused in window 1 + + # Window 2 setup. + _new_window(client) + time.sleep(0.5) + _ensure_workspaces(client, 2) + client.select_workspace(0) + client.send(f"echo {markers['w2_ws0']}\n") + if not _wait_for_marker(client, markers["w2_ws0"]): + failures.append("missing marker for window2 workspace0 during setup") + client.select_workspace(1) + client.send(f"echo {markers['w2_ws1']}\n") + if not _wait_for_marker(client, markers["w2_ws1"]): + failures.append("missing marker for window2 workspace1 during setup") + client.select_workspace(0) # leave workspace 1 unfocused in window 2 + finally: + client.close() + _quit(bundle_id, socket_path) + + # Launch 2: immediate quit without focusing unfocused workspaces. + socket_path = _launch(app_path, socket_path) + client = _connect(socket_path) + try: + window_ids = _list_windows(client) + if len(window_ids) < 2: + failures.append(f"expected >=2 windows after first relaunch, got {len(window_ids)}") + finally: + client.close() + _quit(bundle_id, socket_path) + + # Launch 3: verify all markers still present across windows/workspaces. + socket_path = _launch(app_path, socket_path) + client = _connect(socket_path) + try: + window_ids = _list_windows(client) + if len(window_ids) < 2: + failures.append(f"expected >=2 windows after second relaunch, got {len(window_ids)}") + + remaining = set(markers.values()) + for window_id in window_ids: + _focus_window(client, window_id) + time.sleep(0.3) + workspace_count = len(client.list_workspaces()) + for idx in range(min(workspace_count, 2)): + client.select_workspace(idx) + _consume_visible_markers(client, remaining, timeout=6.0) + if not remaining: + break + if not remaining: + break + + if remaining: + failures.append(f"missing markers after second relaunch: {sorted(remaining)}") + finally: + client.close() + _quit(bundle_id, socket_path) + finally: + _kill_existing(app_path) + socket_path.unlink(missing_ok=True) + snapshot.unlink(missing_ok=True) + + if failures: + print("FAIL:") + for failure in failures: + print(f"- {failure}") + return 1 + + print("PASS: multi-window unfocused workspaces survive repeated relaunch") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_session_restore_unfocused_workspace_relaunch_cycle.py b/tests/test_session_restore_unfocused_workspace_relaunch_cycle.py new file mode 100644 index 00000000..87164820 --- /dev/null +++ b/tests/test_session_restore_unfocused_workspace_relaunch_cycle.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +""" +Regression: unfocused restored workspaces must survive a second relaunch. + +Repro for the historical bug: +1) Launch and save workspaces with marker scrollback. +2) Relaunch, do not focus the non-selected workspaces, then quit again. +3) Relaunch and verify marker scrollback still exists for every workspace. +""" + +from __future__ import annotations + +import os +import plistlib +import re +import socket +import subprocess +import time +from pathlib import Path + +from cmux import cmux + + +def _bundle_id(app_path: Path) -> str: + info_path = app_path / "Contents" / "Info.plist" + if not info_path.exists(): + raise RuntimeError(f"Missing Info.plist at {info_path}") + with info_path.open("rb") as f: + info = plistlib.load(f) + bundle_id = str(info.get("CFBundleIdentifier", "")).strip() + if not bundle_id: + raise RuntimeError("Missing CFBundleIdentifier") + return bundle_id + + +def _snapshot_path(bundle_id: str) -> Path: + safe_bundle = re.sub(r"[^A-Za-z0-9._-]", "_", bundle_id) + return Path.home() / "Library/Application Support/cmux" / f"session-{safe_bundle}.json" + + +def _socket_reachable(socket_path: Path) -> bool: + if not socket_path.exists(): + return False + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + sock.settimeout(0.3) + sock.connect(str(socket_path)) + sock.sendall(b"ping\n") + data = sock.recv(1024) + return b"PONG" in data + except OSError: + return False + finally: + sock.close() + + +def _wait_for_socket(socket_path: Path, timeout: float = 20.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + if _socket_reachable(socket_path): + return + time.sleep(0.2) + raise RuntimeError(f"Socket did not become reachable: {socket_path}") + + +def _wait_for_socket_closed(socket_path: Path, timeout: float = 20.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + if not _socket_reachable(socket_path): + return + time.sleep(0.2) + raise RuntimeError(f"Socket still reachable after quit: {socket_path}") + + +def _kill_existing(app_path: Path) -> None: + exe = app_path / "Contents" / "MacOS" / "cmux DEV" + subprocess.run(["pkill", "-f", str(exe)], capture_output=True, text=True) + time.sleep(1.0) + + +def _launch(app_path: Path, socket_path: Path) -> None: + try: + socket_path.unlink() + except FileNotFoundError: + pass + subprocess.run( + [ + "open", + "-na", + str(app_path), + "--env", + f"CMUX_SOCKET_PATH={socket_path}", + "--env", + "CMUX_ALLOW_SOCKET_OVERRIDE=1", + ], + check=True, + ) + _wait_for_socket(socket_path) + time.sleep(1.5) + + +def _quit(bundle_id: str, socket_path: Path) -> None: + subprocess.run( + ["osascript", "-e", f'tell application id "{bundle_id}" to quit'], + capture_output=True, + text=True, + check=True, + ) + _wait_for_socket_closed(socket_path) + try: + socket_path.unlink() + except FileNotFoundError: + pass + time.sleep(0.8) + + +def _connect(socket_path: Path) -> cmux: + client = cmux(socket_path=str(socket_path)) + client.connect() + if not client.ping(): + raise RuntimeError("ping failed") + return client + + +def _read_scrollback(client: cmux) -> str: + return client._send_command("read_screen --scrollback") + + +def _wait_for_marker(client: cmux, marker: str, timeout: float = 8.0) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + if marker in _read_scrollback(client): + return True + time.sleep(0.25) + return False + + +def main() -> int: + app_path_str = os.environ.get("CMUX_APP_PATH", "").strip() + if not app_path_str: + print("SKIP: set CMUX_APP_PATH to a built cmux DEV .app path") + return 0 + app_path = Path(app_path_str) + if not app_path.exists(): + print(f"SKIP: CMUX_APP_PATH does not exist: {app_path}") + return 0 + + bundle_id = _bundle_id(app_path) + snapshot = _snapshot_path(bundle_id) + socket_path = Path(f"/tmp/cmux-session-restore-cycle-{bundle_id.replace('.', '-')}.sock") + + markers = [f"CMUX_RESTORE_EDGE_{i}" for i in range(3)] + failures: list[str] = [] + + _kill_existing(app_path) + snapshot.unlink(missing_ok=True) + + try: + # First launch: seed three workspaces with marker scrollback. + _launch(app_path, socket_path) + client = _connect(socket_path) + try: + while len(client.list_workspaces()) < 3: + client.new_workspace() + time.sleep(0.3) + + for idx, marker in enumerate(markers): + client.select_workspace(idx) + time.sleep(0.4) + client.send(f"echo {marker}\n") + if not _wait_for_marker(client, marker, timeout=6.0): + failures.append(f"setup marker missing in workspace {idx}: {marker}") + + # Keep selected workspace deterministic. + client.select_workspace(1) + time.sleep(0.3) + finally: + client.close() + _quit(bundle_id, socket_path) + + # Second launch: do not focus unfocused workspaces. Quit immediately. + _launch(app_path, socket_path) + client = _connect(socket_path) + try: + restored = client.list_workspaces() + if len(restored) < 3: + failures.append(f"expected >=3 workspaces after first relaunch, got {len(restored)}") + selected_indices = [idx for idx, _wid, _title, selected in restored if selected] + if selected_indices != [1]: + failures.append(f"expected selected workspace index [1], got {selected_indices}") + finally: + client.close() + _quit(bundle_id, socket_path) + + # Third launch: every workspace should still contain its marker. + _launch(app_path, socket_path) + client = _connect(socket_path) + try: + restored = client.list_workspaces() + if len(restored) < 3: + failures.append(f"expected >=3 workspaces after second relaunch, got {len(restored)}") + + for idx, marker in enumerate(markers): + client.select_workspace(idx) + if not _wait_for_marker(client, marker, timeout=8.0): + tail = "\n".join(_read_scrollback(client).splitlines()[-10:]) + failures.append( + f"workspace {idx} missing marker {marker} after second relaunch; tail:\n{tail}" + ) + finally: + client.close() + _quit(bundle_id, socket_path) + finally: + _kill_existing(app_path) + socket_path.unlink(missing_ok=True) + snapshot.unlink(missing_ok=True) + + if failures: + print("FAIL:") + for failure in failures: + print(f"- {failure}") + return 1 + + print("PASS: unfocused workspace scrollback survives repeated relaunch") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_shell_scrollback_restore_color_replay.py b/tests/test_shell_scrollback_restore_color_replay.py new file mode 100644 index 00000000..fed1088e --- /dev/null +++ b/tests/test_shell_scrollback_restore_color_replay.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +""" +Regression: ANSI color escape bytes in replay content must be preserved. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + + +def main() -> int: + root = Path(__file__).resolve().parents[1] + integration_script = root / "Resources" / "shell-integration" / "cmux-zsh-integration.zsh" + if not integration_script.exists(): + print(f"SKIP: missing zsh integration script at {integration_script}") + return 0 + + base = Path("/tmp") / f"cmux_scrollback_color_replay_{os.getpid()}" + try: + shutil.rmtree(base, ignore_errors=True) + base.mkdir(parents=True, exist_ok=True) + + replay_file = base / "replay.bin" + replay_file.write_bytes(b"\x1b[31mRED\x1b[0m\n") + + env = dict(os.environ) + env["PATH"] = str(base / "empty-bin") + env["CMUX_RESTORE_SCROLLBACK_FILE"] = str(replay_file) + env["CMUX_TEST_INTEGRATION_SCRIPT"] = str(integration_script) + + result = subprocess.run( + ["/bin/zsh", "-f", "-c", 'source "$CMUX_TEST_INTEGRATION_SCRIPT"'], + env=env, + capture_output=True, + timeout=5, + ) + if result.returncode != 0: + print(f"FAIL: zsh exited non-zero rc={result.returncode}") + if result.stderr: + print(result.stderr.decode("utf-8", errors="replace").strip()) + return 1 + + output = (result.stdout or b"") + (result.stderr or b"") + if b"\x1b[31mRED\x1b[0m" not in output: + print("FAIL: ANSI color escape sequence not preserved in replay output") + return 1 + + if replay_file.exists(): + print("FAIL: replay file was not deleted after replay") + return 1 + + print("PASS: ANSI color escape sequence preserved during replay") + return 0 + finally: + shutil.rmtree(base, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_shell_scrollback_restore_replay_path_regression.py b/tests/test_shell_scrollback_restore_replay_path_regression.py new file mode 100644 index 00000000..2f7d549e --- /dev/null +++ b/tests/test_shell_scrollback_restore_replay_path_regression.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +""" +Regression: scrollback replay must not depend on PATH containing coreutils. + +cmux can launch shells with PATH initially pointing at app resources. If replay +relies on bare `cat`/`rm`, startup replay silently fails before user rc files +restore PATH. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + + +def main() -> int: + root = Path(__file__).resolve().parents[1] + integration_script = root / "Resources" / "shell-integration" / "cmux-zsh-integration.zsh" + if not integration_script.exists(): + print(f"SKIP: missing zsh integration script at {integration_script}") + return 0 + + base = Path("/tmp") / f"cmux_scrollback_restore_{os.getpid()}" + try: + shutil.rmtree(base, ignore_errors=True) + base.mkdir(parents=True, exist_ok=True) + + replay_file = base / "replay.txt" + replay_file.write_text("scrollback-line-1\nscrollback-line-2\n", encoding="utf-8") + + env = dict(os.environ) + env["PATH"] = str(base / "empty-bin") + env["CMUX_RESTORE_SCROLLBACK_FILE"] = str(replay_file) + env["CMUX_TEST_INTEGRATION_SCRIPT"] = str(integration_script) + + result = subprocess.run( + ["/bin/zsh", "-f", "-c", 'source "$CMUX_TEST_INTEGRATION_SCRIPT"'], + env=env, + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode != 0: + print(f"FAIL: zsh exited non-zero rc={result.returncode}") + if result.stderr.strip(): + print(result.stderr.strip()) + return 1 + + output = (result.stdout or "") + (result.stderr or "") + if "scrollback-line-1" not in output or "scrollback-line-2" not in output: + print("FAIL: replay text was not printed during integration startup") + return 1 + + if replay_file.exists(): + print("FAIL: replay file was not deleted after replay") + return 1 + + print("PASS: scrollback replay works with minimal PATH") + return 0 + finally: + shutil.rmtree(base, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main())