Implement session persistence pass 1 with multi-window restore

This commit is contained in:
Lawrence Chen 2026-02-22 15:39:59 -08:00
parent 1809b06867
commit 927b0eb2d1
20 changed files with 3434 additions and 33 deletions

View file

@ -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 = "<group>"; };
A5001223 /* UpdateLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateLogStore.swift; sourceTree = "<group>"; };
A5001241 /* WindowDecorationsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDecorationsController.swift; sourceTree = "<group>"; };
A5001601 /* SessionPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistence.swift; sourceTree = "<group>"; };
818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = "<group>"; };
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = "<group>"; };
A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@ -197,6 +201,8 @@
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = "<group>"; };
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = "<group>"; };
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = "<group>"; };
F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = "<group>"; };
F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = "<group>"; };
/* 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 = "<group>";
@ -402,6 +409,8 @@
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */,
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */,
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */,
F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */,
F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */,
);
path = cmuxTests;
sourceTree = "<group>";
@ -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;
};

View file

@ -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:-}"

View file

@ -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=""

File diff suppressed because it is too large Load diff

View file

@ -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 {

View file

@ -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)

View file

@ -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)

View file

@ -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[..<initialStart].lastIndex(of: Character(escape)) else {
return initialStart
}
let csiMarker = text.index(after: lastEscape)
guard csiMarker < text.endIndex, text[csiMarker] == "[" else {
return initialStart
}
// If a final CSI byte exists before the truncation boundary, we are not
// inside a partial sequence.
if csiFinalByteIndex(in: text, from: csiMarker, upperBound: initialStart) != nil {
return initialStart
}
// We are inside a CSI sequence. Skip to the first character after the
// sequence terminator if it exists.
guard let final = csiFinalByteIndex(in: text, from: csiMarker, upperBound: text.endIndex) else {
return initialStart
}
let next = text.index(after: final)
return next < text.endIndex ? next : text.endIndex
}
private static func csiFinalByteIndex(
in text: String,
from csiMarker: String.Index,
upperBound: String.Index
) -> 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
}
}
}

View file

@ -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
}
}

View file

@ -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

View file

@ -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)

View file

@ -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 {

View file

@ -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<UUID> = []
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<UUID>()
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)
}

View file

@ -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()
}
}
}

View file

@ -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))
}
}

View file

@ -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]
)
}
}

View file

@ -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())

View file

@ -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())

View file

@ -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())

View file

@ -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())