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