Implement session persistence pass 1 with multi-window restore
This commit is contained in:
parent
1809b06867
commit
927b0eb2d1
20 changed files with 3434 additions and 33 deletions
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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:-}"
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
471
Sources/SessionPersistence.swift
Normal file
471
Sources/SessionPersistence.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
214
cmuxTests/AppDelegateShortcutRoutingTests.swift
Normal file
214
cmuxTests/AppDelegateShortcutRoutingTests.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
382
cmuxTests/SessionPersistenceTests.swift
Normal file
382
cmuxTests/SessionPersistenceTests.swift
Normal 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]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
229
tests/test_session_restore_unfocused_workspace_relaunch_cycle.py
Normal file
229
tests/test_session_restore_unfocused_workspace_relaunch_cycle.py
Normal 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())
|
||||
62
tests/test_shell_scrollback_restore_color_replay.py
Normal file
62
tests/test_shell_scrollback_restore_color_replay.py
Normal 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())
|
||||
|
|
@ -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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue