From 58de044f4fd8b1531994fd60c28c74a1e38ef744 Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Tue, 17 Mar 2026 22:07:19 -0700
Subject: [PATCH 01/24] Rename "Import From Browser" to "Import Browser Data"
(#1672)
Co-authored-by: Lawrence Chen
---
Resources/Localizable.xcstrings | 8 ++++----
Sources/ContentView.swift | 2 +-
Sources/Panels/BrowserPanelView.swift | 2 +-
Sources/cmuxApp.swift | 6 +++---
4 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings
index 9cf30d6c..a1a73b9d 100644
--- a/Resources/Localizable.xcstrings
+++ b/Resources/Localizable.xcstrings
@@ -38278,13 +38278,13 @@
"en": {
"stringUnit": {
"state": "translated",
- "value": "Import From Browser…"
+ "value": "Import Browser Data…"
}
},
"ja": {
"stringUnit": {
"state": "translated",
- "value": "ブラウザーから取り込む…"
+ "value": "ブラウザーデータを取り込む…"
}
}
}
@@ -51224,13 +51224,13 @@
"en": {
"stringUnit": {
"state": "translated",
- "value": "Import From Browser"
+ "value": "Import Browser Data"
}
},
"ja": {
"stringUnit": {
"state": "translated",
- "value": "ブラウザーから取り込む"
+ "value": "ブラウザーデータを取り込む"
}
}
}
diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift
index f7debf31..8242588b 100644
--- a/Sources/ContentView.swift
+++ b/Sources/ContentView.swift
@@ -9809,7 +9809,7 @@ private struct SidebarHelpMenuButton: View {
isExternalLink: false
)
helpOptionButton(
- title: String(localized: "menu.view.importFromBrowser", defaultValue: "Import From Browser…"),
+ title: String(localized: "menu.view.importFromBrowser", defaultValue: "Import Browser Data…"),
action: .importBrowserData,
accessibilityIdentifier: "SidebarHelpMenuOptionImportBrowserData",
isExternalLink: false
diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift
index 6bffccb2..83f072a8 100644
--- a/Sources/Panels/BrowserPanelView.swift
+++ b/Sources/Panels/BrowserPanelView.swift
@@ -965,7 +965,7 @@ struct BrowserPanelView: View {
Button {
presentImportDialogFromProfileMenu()
} label: {
- Text(String(localized: "menu.view.importFromBrowser", defaultValue: "Import From Browser…"))
+ Text(String(localized: "menu.view.importFromBrowser", defaultValue: "Import Browser Data…"))
.font(.system(size: 12))
}
.buttonStyle(.plain)
diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift
index c0700045..eb17ccd7 100644
--- a/Sources/cmuxApp.swift
+++ b/Sources/cmuxApp.swift
@@ -628,7 +628,7 @@ struct cmuxApp: App {
BrowserHistoryStore.shared.clearHistory()
}
- Button(String(localized: "menu.view.importFromBrowser", defaultValue: "Import From Browser…")) {
+ Button(String(localized: "menu.view.importFromBrowser", defaultValue: "Import Browser Data…")) {
// Defer modal presentation until after AppKit finishes menu tracking.
DispatchQueue.main.async {
BrowserDataImportCoordinator.shared.presentImportDialog()
@@ -2167,7 +2167,7 @@ private struct BrowserProfilePopoverDebugView: View {
Text(String(localized: "browser.profile.new", defaultValue: "New Profile..."))
.font(.system(size: 12))
- Text(String(localized: "menu.view.importFromBrowser", defaultValue: "Import From Browser…"))
+ Text(String(localized: "menu.view.importFromBrowser", defaultValue: "Import Browser Data…"))
.font(.system(size: 12))
}
.padding(.horizontal, BrowserProfilePopoverDebugSettings.resolvedHorizontalPadding(horizontalPaddingRaw))
@@ -4880,7 +4880,7 @@ struct SettingsView: View {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 8) {
- Text(String(localized: "settings.browser.import", defaultValue: "Import From Browser"))
+ Text(String(localized: "settings.browser.import", defaultValue: "Import Browser Data"))
.font(.system(size: 13, weight: .semibold))
VStack(alignment: .leading, spacing: 6) {
From b64fb301c1414def3285993188d8282de882f900 Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Tue, 17 Mar 2026 22:07:45 -0700
Subject: [PATCH 02/24] fix(terminal): execute Return after Korean IME commit
(#1671)
* test(terminal): cover Return after Korean IME commit
* fix(terminal): execute Return after Korean IME commit
---------
Co-authored-by: Lawrence Chen
---
Sources/GhosttyTerminalView.swift | 25 +++++
cmuxTests/CJKIMEInputTests.swift | 148 ++++++++++++++++++++++++++++++
2 files changed, 173 insertions(+)
diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift
index 56009f7e..e6d16ea2 100644
--- a/Sources/GhosttyTerminalView.swift
+++ b/Sources/GhosttyTerminalView.swift
@@ -5227,6 +5227,26 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
#endif
}
}
+
+ if shouldSendCommittedIMEConfirmKey(
+ event: translationEvent,
+ markedTextBefore: markedTextBefore
+ ) {
+ keyEvent.consumed_mods = GHOSTTY_MODS_NONE
+ keyEvent.text = nil
+#if DEBUG
+ let ghosttySendStart = ProcessInfo.processInfo.systemUptime
+ _ = sendTimedGhosttyKey(
+ surface,
+ keyEvent,
+ path: "terminal.keyDown.accumulatedConfirmGhosttySend",
+ event: event
+ )
+ ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0
+#else
+ _ = ghostty_surface_key(surface, keyEvent)
+#endif
+ }
} else {
// Get the appropriate text for this key event
// For control characters, this returns the unmodified character
@@ -5487,6 +5507,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
return true
}
+ private func shouldSendCommittedIMEConfirmKey(event: NSEvent, markedTextBefore: Bool) -> Bool {
+ guard markedTextBefore, markedText.length == 0 else { return false }
+ return event.keyCode == 36 || event.keyCode == 76
+ }
+
private func ghosttyKeyEvent(for event: NSEvent, surface: ghostty_surface_t) -> ghostty_input_key_s {
var keyEvent = ghostty_input_key_s()
keyEvent.action = GHOSTTY_ACTION_PRESS
diff --git a/cmuxTests/CJKIMEInputTests.swift b/cmuxTests/CJKIMEInputTests.swift
index 849c2616..80c7d8c6 100644
--- a/cmuxTests/CJKIMEInputTests.swift
+++ b/cmuxTests/CJKIMEInputTests.swift
@@ -1,5 +1,6 @@
import XCTest
import AppKit
+import ObjectiveC.runtime
#if canImport(cmux_DEV)
@testable import cmux_DEV
@@ -7,6 +8,64 @@ import AppKit
@testable import cmux
#endif
+private var cjkIMEInterpretKeyEventsSwizzled = false
+private var cjkIMEInterpretKeyEventsHook: ((GhosttyNSView, [NSEvent]) -> Bool)?
+
+private extension GhosttyNSView {
+ @objc func cmuxUnitTest_interpretKeyEvents(_ eventArray: [NSEvent]) {
+ if let hook = cjkIMEInterpretKeyEventsHook, hook(self, eventArray) {
+ return
+ }
+ cmuxUnitTest_interpretKeyEvents(eventArray)
+ }
+}
+
+private func installCJKIMEInterpretKeyEventsSwizzle() {
+ guard !cjkIMEInterpretKeyEventsSwizzled else { return }
+
+ let originalSelector = #selector(GhosttyNSView.interpretKeyEvents(_:))
+ let swizzledSelector = #selector(GhosttyNSView.cmuxUnitTest_interpretKeyEvents(_:))
+
+ guard let originalMethod = class_getInstanceMethod(GhosttyNSView.self, originalSelector),
+ let swizzledMethod = class_getInstanceMethod(GhosttyNSView.self, swizzledSelector) else {
+ fatalError("Unable to locate GhosttyNSView interpretKeyEvents methods for swizzling")
+ }
+
+ let didAddMethod = class_addMethod(
+ GhosttyNSView.self,
+ originalSelector,
+ method_getImplementation(swizzledMethod),
+ method_getTypeEncoding(swizzledMethod)
+ )
+
+ if didAddMethod {
+ class_replaceMethod(
+ GhosttyNSView.self,
+ swizzledSelector,
+ method_getImplementation(originalMethod),
+ method_getTypeEncoding(originalMethod)
+ )
+ } else {
+ method_exchangeImplementations(originalMethod, swizzledMethod)
+ }
+
+ cjkIMEInterpretKeyEventsSwizzled = true
+}
+
+private func findGhosttyNSView(in view: NSView) -> GhosttyNSView? {
+ if let view = view as? GhosttyNSView {
+ return view
+ }
+
+ for subview in view.subviews {
+ if let match = findGhosttyNSView(in: subview) {
+ return match
+ }
+ }
+
+ return nil
+}
+
// MARK: - NSTextInputClient protocol: marked text (preedit) lifecycle
/// Tests that the GhosttyNSView NSTextInputClient implementation correctly
@@ -932,6 +991,95 @@ final class GhosttySpaceReleaseRegressionTests: XCTestCase {
}
}
+@MainActor
+final class KoreanIMEReturnCommitRegressionTests: XCTestCase {
+ func testReturnAfterKoreanCommitAlsoSendsReturnToSurface() {
+ _ = NSApplication.shared
+
+ let surface = TerminalSurface(
+ tabId: UUID(),
+ context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
+ configTemplate: nil,
+ workingDirectory: nil
+ )
+ let hostedView = surface.hostedView
+
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer {
+ GhosttyNSView.debugGhosttySurfaceKeyEventObserver = nil
+ window.orderOut(nil)
+ }
+
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ hostedView.frame = contentView.bounds
+ hostedView.autoresizingMask = [.width, .height]
+ contentView.addSubview(hostedView)
+
+ window.makeKeyAndOrderFront(nil)
+ window.displayIfNeeded()
+ contentView.layoutSubtreeIfNeeded()
+ hostedView.setVisibleInUI(true)
+ hostedView.setActive(true)
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+
+ guard let view = findGhosttyNSView(in: hostedView) else {
+ XCTFail("Expected hosted GhosttyNSView")
+ return
+ }
+
+ view.setMarkedText("한", selectedRange: NSRange(location: 0, length: 1), replacementRange: NSRange(location: NSNotFound, length: 0))
+
+ installCJKIMEInterpretKeyEventsSwizzle()
+ cjkIMEInterpretKeyEventsHook = { candidateView, _ in
+ guard candidateView === view else { return false }
+ candidateView.insertText("한", replacementRange: NSRange(location: NSNotFound, length: 0))
+ return true
+ }
+ defer {
+ cjkIMEInterpretKeyEventsHook = nil
+ }
+
+ var sawReturnPress = false
+ GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in
+ guard keyEvent.action == GHOSTTY_ACTION_PRESS,
+ keyEvent.keycode == 36,
+ keyEvent.text == nil else { return }
+ sawReturnPress = true
+ }
+
+ guard let event = NSEvent.keyEvent(
+ with: .keyDown,
+ location: .zero,
+ modifierFlags: [],
+ timestamp: ProcessInfo.processInfo.systemUptime,
+ windowNumber: window.windowNumber,
+ context: nil,
+ characters: "\r",
+ charactersIgnoringModifiers: "\r",
+ isARepeat: false,
+ keyCode: 36
+ ) else {
+ XCTFail("Failed to create Return event")
+ return
+ }
+
+ window.makeFirstResponder(view)
+ view.keyDown(with: event)
+
+ XCTAssertFalse(view.hasMarkedText(), "Return should commit the active Hangul composition")
+ XCTAssertTrue(sawReturnPress, "Return should still be forwarded after IME commit so the command executes once")
+ }
+}
+
final class GhosttyBackquoteRegressionTests: XCTestCase {
func testShiftBackquoteEscFallbackSendsLiteralTilde() {
_ = NSApplication.shared
From bdd95902f245d410c26855dd3745cc00856a8d11 Mon Sep 17 00:00:00 2001
From: Austin Wang
Date: Tue, 17 Mar 2026 22:36:32 -0700
Subject: [PATCH 03/24] Restore last-surface close preference toggle (#1679)
* test: cover last-surface close preference regression
* fix: restore last-surface close preference
---
Resources/Localizable.xcstrings | 12 +-
Sources/TabManager.swift | 32 ++++-
Sources/Workspace.swift | 6 +-
Sources/cmuxApp.swift | 45 ++++++-
.../AppDelegateShortcutRoutingTests.swift | 59 ++++++++++
cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 110 ++++++++++++++++++
6 files changed, 248 insertions(+), 16 deletions(-)
diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings
index a1a73b9d..11f77ec7 100644
--- a/Resources/Localizable.xcstrings
+++ b/Resources/Localizable.xcstrings
@@ -43256,13 +43256,13 @@
"en": {
"stringUnit": {
"state": "translated",
- "value": "Closing Last Surface Closes Workspace"
+ "value": "Keep Workspace Open When Closing Last Surface"
}
},
"ja": {
"stringUnit": {
"state": "translated",
- "value": "最後のサーフェスを閉じるとワークスペースも閉じる"
+ "value": "最後のサーフェスを閉じてもワークスペースを残す"
}
}
}
@@ -43273,13 +43273,13 @@
"en": {
"stringUnit": {
"state": "translated",
- "value": "Closing the last surface keeps the workspace open. Use Cmd+Shift+W to close a workspace explicitly."
+ "value": "When the focused surface is the last one in its workspace, the close-surface shortcut also closes the workspace."
}
},
"ja": {
"stringUnit": {
"state": "translated",
- "value": "最後のサーフェスを閉じてもワークスペースは残ります。ワークスペースを明示的に閉じるにはCmd+Shift+Wを使います。"
+ "value": "フォーカス中のサーフェスがそのワークスペースの最後の1つなら、サーフェスを閉じるショートカットはワークスペースも閉じます。"
}
}
}
@@ -43290,13 +43290,13 @@
"en": {
"stringUnit": {
"state": "translated",
- "value": "Closing the last surface also closes its workspace."
+ "value": "When the focused surface is the last one in its workspace, the close-surface shortcut closes only the surface and keeps the workspace open. Use the close-workspace shortcut to close the workspace explicitly."
}
},
"ja": {
"stringUnit": {
"state": "translated",
- "value": "最後のサーフェスを閉じると、そのワークスペースも閉じます。"
+ "value": "フォーカス中のサーフェスがそのワークスペースの最後の1つでも、サーフェスを閉じるショートカットはサーフェスだけを閉じ、ワークスペースは残します。ワークスペースを閉じるショートカットを使うと明示的に閉じられます。"
}
}
}
diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift
index 97271038..783f0e7c 100644
--- a/Sources/TabManager.swift
+++ b/Sources/TabManager.swift
@@ -60,6 +60,20 @@ enum WorkspaceAutoReorderSettings {
}
}
+enum LastSurfaceCloseShortcutSettings {
+ static let key = "closeWorkspaceOnLastSurfaceShortcut"
+ // Keep the legacy stored meaning so existing values still map to the same
+ // behavior. The default is flipped to preserve current Cmd+W behavior.
+ static let defaultValue = true
+
+ static func closesWorkspace(defaults: UserDefaults = .standard) -> Bool {
+ if defaults.object(forKey: key) == nil {
+ return defaultValue
+ }
+ return defaults.bool(forKey: key)
+ }
+}
+
enum SidebarBranchLayoutSettings {
static let key = "sidebarBranchVerticalLayout"
static let defaultVerticalLayout = true
@@ -2101,6 +2115,12 @@ class TabManager: ObservableObject {
}
}
+ private func shouldCloseWorkspaceOnLastSurfaceShortcut(_ workspace: Workspace, panelId: UUID) -> Bool {
+ LastSurfaceCloseShortcutSettings.closesWorkspace() &&
+ workspace.panels.count <= 1 &&
+ workspace.panels[panelId] != nil
+ }
+
private func closePanelWithConfirmation(tab: Workspace, panelId: UUID) {
guard tab.panels[panelId] != nil else {
#if DEBUG
@@ -2121,18 +2141,20 @@ class TabManager: ObservableObject {
if panel is BrowserPanel { return "browser" }
return String(describing: type(of: panel))
}()
+ let closesWorkspaceOnLastSurfaceShortcut = shouldCloseWorkspaceOnLastSurfaceShortcut(tab, panelId: panelId)
#if DEBUG
dlog(
"surface.close.shortcut.begin tab=\(tab.id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) kind=\(panelKind) " +
- "panelCount=\(tab.panels.count) bonsplitTabs=\(bonsplitTabCount)"
+ "panelCount=\(tab.panels.count) bonsplitTabs=\(bonsplitTabCount) " +
+ "closeWorkspaceOnLastSurface=\(closesWorkspaceOnLastSurfaceShortcut ? 1 : 0)"
)
#endif
- // Route Cmd+W through Bonsplit/Workspace close handling so it matches the tab close
- // button, including shared confirmation, last-surface workspace/window-close behavior,
- // and the usual replacement-panel flow when the close does not collapse the workspace.
- if let surfaceId = tab.surfaceIdFromPanelId(panelId) {
+ // The last-surface shortcut preference only affects Cmd+W. The tab close button
+ // continues to use Workspace's explicit-close path when it closes the last surface.
+ if closesWorkspaceOnLastSurfaceShortcut,
+ let surfaceId = tab.surfaceIdFromPanelId(panelId) {
tab.markExplicitClose(surfaceId: surfaceId)
}
let closed = tab.closePanel(panelId)
diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift
index d40006d0..444f7170 100644
--- a/Sources/Workspace.swift
+++ b/Sources/Workspace.swift
@@ -5164,8 +5164,10 @@ final class Workspace: Identifiable, ObservableObject {
/// Prevents repeated close gestures (e.g., middle-click spam) from stacking dialogs.
private var pendingCloseConfirmTabIds: Set = []
- /// Tab IDs whose next close attempt came from an explicit user close gesture
- /// (Cmd+W or the tab-strip X button), rather than an internal close/move flow.
+ /// Tab IDs whose next close attempt should be treated as an explicit
+ /// workspace-close gesture from the user (the tab-strip X button, or Cmd+W when
+ /// the shortcut preference is set to close the workspace on the last surface),
+ /// rather than an internal close/move flow.
private var explicitUserCloseTabIds: Set = []
/// Deterministic tab selection to apply after a tab closes.
diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift
index eb17ccd7..ca6f5e78 100644
--- a/Sources/cmuxApp.swift
+++ b/Sources/cmuxApp.swift
@@ -493,9 +493,10 @@ struct cmuxApp: App {
Divider()
// Terminal semantics:
- // Cmd+W closes the focused tab/surface (with confirmation if needed). When that
- // was the last surface in the workspace, cmux removes the workspace and closes
- // the window if it was also the last workspace.
+ // Cmd+W closes the focused tab/surface (with confirmation if needed). By
+ // default, closing the last surface also closes the workspace and the window
+ // if it was also the last workspace. Users can opt into keeping the workspace
+ // open instead.
Button(String(localized: "menu.file.closeTab", defaultValue: "Close Tab")) {
closePanelOrWindow()
}
@@ -3593,6 +3594,8 @@ struct SettingsView: View {
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey)
private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
@AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
+ @AppStorage(LastSurfaceCloseShortcutSettings.key)
+ private var closeWorkspaceOnLastSurfaceShortcut = LastSurfaceCloseShortcutSettings.defaultValue
@AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
@AppStorage(SidebarWorkspaceDetailSettings.hideAllDetailsKey)
private var sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails
@@ -3645,6 +3648,30 @@ struct SettingsView: View {
NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement
}
+ private var keepWorkspaceOpenOnLastSurfaceShortcut: Bool {
+ !closeWorkspaceOnLastSurfaceShortcut
+ }
+
+ private var keepWorkspaceOpenOnLastSurfaceShortcutBinding: Binding {
+ Binding(
+ get: { keepWorkspaceOpenOnLastSurfaceShortcut },
+ set: { closeWorkspaceOnLastSurfaceShortcut = !$0 }
+ )
+ }
+
+ private var closeWorkspaceOnLastSurfaceShortcutSubtitle: String {
+ if keepWorkspaceOpenOnLastSurfaceShortcut {
+ return String(
+ localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut.subtitleOn",
+ defaultValue: "When the focused surface is the last one in its workspace, the close-surface shortcut closes only the surface and keeps the workspace open. Use the close-workspace shortcut to close the workspace explicitly."
+ )
+ }
+ return String(
+ localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut.subtitleOff",
+ defaultValue: "When the focused surface is the last one in its workspace, the close-surface shortcut also closes the workspace."
+ )
+ }
+
private var selectedSidebarActiveTabIndicatorStyle: SidebarActiveTabIndicatorStyle {
SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: sidebarActiveTabIndicatorStyle)
}
@@ -4076,6 +4103,17 @@ struct SettingsView: View {
SettingsCardDivider()
+ SettingsCardRow(
+ String(localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut", defaultValue: "Keep Workspace Open When Closing Last Surface"),
+ subtitle: closeWorkspaceOnLastSurfaceShortcutSubtitle
+ ) {
+ Toggle("", isOn: keepWorkspaceOpenOnLastSurfaceShortcutBinding)
+ .labelsHidden()
+ .controlSize(.small)
+ }
+
+ SettingsCardDivider()
+
SettingsCardRow(
String(localized: "settings.app.reorderOnNotification", defaultValue: "Reorder on Notification"),
subtitle: String(localized: "settings.app.reorderOnNotification.subtitle", defaultValue: "Move workspaces to the top when they receive a notification. Disable for stable shortcut positions.")
@@ -5228,6 +5266,7 @@ struct SettingsView: View {
ShortcutHintDebugSettings.resetVisibilityDefaults()
alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
+ closeWorkspaceOnLastSurfaceShortcut = LastSurfaceCloseShortcutSettings.defaultValue
workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails
sidebarShowNotificationMessage = SidebarWorkspaceDetailSettings.defaultShowNotificationMessage
diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift
index 4c320f12..2dbc01ab 100644
--- a/cmuxTests/AppDelegateShortcutRoutingTests.swift
+++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift
@@ -6,6 +6,8 @@ import XCTest
@testable import cmux
#endif
+private let lastSurfaceCloseShortcutDefaultsKey = "closeWorkspaceOnLastSurfaceShortcut"
+
@MainActor
final class AppDelegateShortcutRoutingTests: XCTestCase {
private var savedShortcutsByAction: [KeyboardShortcutSettings.Action: StoredShortcut] = [:]
@@ -714,6 +716,63 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
)
}
+ func testCmdWKeepsLastSurfaceWorkspaceOpenWhenKeepWorkspaceOpenPreferenceIsEnabled() throws {
+ guard let appDelegate = AppDelegate.shared else {
+ XCTFail("Expected AppDelegate.shared")
+ return
+ }
+
+ let defaults = UserDefaults.standard
+ let originalSetting = defaults.object(forKey: lastSurfaceCloseShortcutDefaultsKey)
+ defaults.set(false, forKey: lastSurfaceCloseShortcutDefaultsKey)
+ defer {
+ if let originalSetting {
+ defaults.set(originalSetting, forKey: lastSurfaceCloseShortcutDefaultsKey)
+ } else {
+ defaults.removeObject(forKey: lastSurfaceCloseShortcutDefaultsKey)
+ }
+ }
+
+ let windowId = appDelegate.createMainWindow()
+ defer { closeWindow(withId: windowId) }
+
+ guard let targetWindow = window(withId: windowId),
+ let manager = appDelegate.tabManagerFor(windowId: windowId),
+ let workspace = manager.selectedWorkspace,
+ let initialPanelId = workspace.focusedPanelId else {
+ XCTFail("Expected test window, manager, workspace, and focused panel")
+ return
+ }
+
+ guard let event = makeKeyDownEvent(
+ key: "w",
+ modifiers: [.command],
+ keyCode: 13,
+ windowNumber: targetWindow.windowNumber
+ ) else {
+ XCTFail("Failed to construct Cmd+W 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))
+
+ XCTAssertNotNil(
+ self.window(withId: windowId),
+ "Cmd+W should keep the window open when the keep-workspace-open preference is enabled"
+ )
+ XCTAssertEqual(manager.tabs.count, 1)
+ XCTAssertEqual(manager.selectedTabId, workspace.id)
+ XCTAssertNil(workspace.panels[initialPanelId])
+ XCTAssertEqual(workspace.panels.count, 1)
+ XCTAssertNotEqual(workspace.focusedPanelId, initialPanelId)
+ }
+
func testCmdWClosesAuxiliaryWindowInsteadOfMainTerminalPanel() throws {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
index 96b4083b..9f0d08f9 100644
--- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
+++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
@@ -14,6 +14,8 @@ import UserNotifications
@testable import cmux
#endif
+private let lastSurfaceCloseShortcutDefaultsKey = "closeWorkspaceOnLastSurfaceShortcut"
+
private var cmuxUnitTestInspectorAssociationKey: UInt8 = 0
private var cmuxUnitTestInspectorOverrideInstalled = false
@@ -5017,6 +5019,43 @@ final class WorkspaceAutoReorderSettingsTests: XCTestCase {
}
}
+final class LastSurfaceCloseShortcutSettingsTests: XCTestCase {
+ func testDefaultClosesWorkspace() {
+ let suiteName = "LastSurfaceCloseShortcutSettingsTests.Default.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ XCTAssertTrue(LastSurfaceCloseShortcutSettings.closesWorkspace(defaults: defaults))
+ }
+
+ func testStoredTrueClosesWorkspace() {
+ let suiteName = "LastSurfaceCloseShortcutSettingsTests.Enabled.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ defaults.set(true, forKey: LastSurfaceCloseShortcutSettings.key)
+ XCTAssertTrue(LastSurfaceCloseShortcutSettings.closesWorkspace(defaults: defaults))
+ }
+
+ func testStoredFalseKeepsWorkspaceOpen() {
+ let suiteName = "LastSurfaceCloseShortcutSettingsTests.Disabled.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ defaults.set(false, forKey: LastSurfaceCloseShortcutSettings.key)
+ XCTAssertFalse(LastSurfaceCloseShortcutSettings.closesWorkspace(defaults: defaults))
+ }
+}
+
final class SidebarBranchLayoutSettingsTests: XCTestCase {
func testDefaultUsesVerticalLayout() {
let suiteName = "SidebarBranchLayoutSettingsTests.Default.\(UUID().uuidString)"
@@ -5823,6 +5862,39 @@ final class TabManagerCloseCurrentPanelTests: XCTestCase {
XCTAssertTrue(secondWorkspace.panels.isEmpty)
}
+ func testCloseCurrentPanelKeepsWorkspaceOpenWhenKeepWorkspaceOpenPreferenceIsEnabled() {
+ let defaults = UserDefaults.standard
+ let originalSetting = defaults.object(forKey: lastSurfaceCloseShortcutDefaultsKey)
+ defaults.set(false, forKey: lastSurfaceCloseShortcutDefaultsKey)
+ defer {
+ if let originalSetting {
+ defaults.set(originalSetting, forKey: lastSurfaceCloseShortcutDefaultsKey)
+ } else {
+ defaults.removeObject(forKey: lastSurfaceCloseShortcutDefaultsKey)
+ }
+ }
+
+ let manager = TabManager()
+ guard let workspace = manager.selectedWorkspace,
+ let initialPanelId = workspace.focusedPanelId else {
+ XCTFail("Expected selected workspace and focused panel")
+ return
+ }
+
+ let initialWorkspaceId = workspace.id
+
+ manager.closeCurrentPanelWithConfirmation()
+ drainMainQueue()
+ drainMainQueue()
+
+ XCTAssertEqual(manager.tabs.count, 1)
+ XCTAssertEqual(manager.selectedTabId, initialWorkspaceId)
+ XCTAssertEqual(manager.tabs.first?.id, initialWorkspaceId)
+ XCTAssertNil(workspace.panels[initialPanelId])
+ XCTAssertEqual(workspace.panels.count, 1)
+ XCTAssertNotEqual(workspace.focusedPanelId, initialPanelId)
+ }
+
func testClosePanelButtonClosesWorkspaceWhenItOwnsTheLastSurface() {
let manager = TabManager()
let firstWorkspace = manager.tabs[0]
@@ -5853,6 +5925,44 @@ final class TabManagerCloseCurrentPanelTests: XCTestCase {
XCTAssertTrue(secondWorkspace.panels.isEmpty)
}
+ func testClosePanelButtonStillClosesWorkspaceWhenKeepWorkspaceOpenPreferenceIsEnabled() {
+ let defaults = UserDefaults.standard
+ let originalSetting = defaults.object(forKey: lastSurfaceCloseShortcutDefaultsKey)
+ defaults.set(false, forKey: lastSurfaceCloseShortcutDefaultsKey)
+ defer {
+ if let originalSetting {
+ defaults.set(originalSetting, forKey: lastSurfaceCloseShortcutDefaultsKey)
+ } else {
+ defaults.removeObject(forKey: lastSurfaceCloseShortcutDefaultsKey)
+ }
+ }
+
+ let manager = TabManager()
+ let firstWorkspace = manager.tabs[0]
+ let secondWorkspace = manager.addWorkspace()
+ manager.selectWorkspace(secondWorkspace)
+
+ guard let secondPanelId = secondWorkspace.focusedPanelId else {
+ XCTFail("Expected focused panel in selected workspace")
+ return
+ }
+
+ guard let secondSurfaceId = secondWorkspace.surfaceIdFromPanelId(secondPanelId) else {
+ XCTFail("Expected bonsplit surface ID for focused panel")
+ return
+ }
+
+ secondWorkspace.markExplicitClose(surfaceId: secondSurfaceId)
+ XCTAssertFalse(secondWorkspace.closePanel(secondPanelId))
+ drainMainQueue()
+ drainMainQueue()
+
+ XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id])
+ XCTAssertEqual(manager.selectedTabId, firstWorkspace.id)
+ XCTAssertNil(secondWorkspace.panels[secondPanelId])
+ XCTAssertTrue(secondWorkspace.panels.isEmpty)
+ }
+
func testGenericClosePanelKeepsWorkspaceOpenWithoutExplicitCloseMarker() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
From 55cb5c6763ccd07cb302c85554bbd44457fab35b Mon Sep 17 00:00:00 2001
From: Austin Wang
Date: Tue, 17 Mar 2026 22:57:12 -0700
Subject: [PATCH 04/24] Fix sidebar workspace PR status display and false
positives (#1636)
* test(sidebar): add failing PR checks regressions
* fix(sidebar): show workspace PR status
* refactor(sidebar): restore PR icon style
* refactor(sidebar): simplify PR check labels
* test(sidebar): cover focused workspace PR selection
* fix(sidebar): scope workspace PRs to current branch
* test(sidebar): cover stale PR after branch change
* fix(sidebar): clear stale PRs on branch changes
* test(sidebar): cover workspace PR false positives
* fix(sidebar): stop false-positive workspace PR badges
* test(cmuxTests): remove duplicate sidebar PR regressions
---
.../cmux-bash-integration.bash | 62 +--
.../cmux-zsh-integration.zsh | 62 +--
Sources/ContentView.swift | 11 +-
Sources/TabManager.swift | 501 ++++++++++++++----
Sources/TerminalController.swift | 78 ++-
Sources/Workspace.swift | 107 +++-
cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 143 ++++-
.../WorkspacePullRequestSidebarTests.swift | 82 +++
8 files changed, 827 insertions(+), 219 deletions(-)
create mode 100644 cmuxTests/WorkspacePullRequestSidebarTests.swift
diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash
index 46afa112..1981b9f2 100644
--- a/Resources/shell-integration/cmux-bash-integration.bash
+++ b/Resources/shell-integration/cmux-bash-integration.bash
@@ -194,8 +194,6 @@ _cmux_report_pr_for_path() {
[[ -n "$CMUX_PANEL_ID" ]] || return 0
local branch repo_slug="" gh_output="" gh_error="" err_file="" gh_status number state url status_opt=""
- local explicit_branch_output="" explicit_branch_error="" explicit_branch_status=0
- local implicit_probe_indicates_no_pr=0 explicit_probe_indicates_no_pr=0
local -a gh_repo_args=()
branch="$(git -C "$repo_path" branch --show-current 2>/dev/null)"
if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then
@@ -211,7 +209,7 @@ _cmux_report_pr_for_path() {
[[ -n "$err_file" ]] || return 1
gh_output="$(
builtin cd "$repo_path" 2>/dev/null \
- && gh pr view \
+ && gh pr view "$branch" \
"${gh_repo_args[@]}" \
--json number,state,url \
--jq '[.number, .state, .url] | @tsv' \
@@ -223,53 +221,20 @@ _cmux_report_pr_for_path() {
/bin/rm -f -- "$err_file" >/dev/null 2>&1 || true
fi
- if (( gh_status == 0 )) && [[ -n "$gh_output" ]]; then
- :
- else
+ if (( gh_status != 0 )) || [[ -z "$gh_output" ]]; then
if (( gh_status == 0 )) && [[ -z "$gh_output" ]]; then
- implicit_probe_indicates_no_pr=1
- elif _cmux_pr_output_indicates_no_pull_request "$gh_error"; then
- implicit_probe_indicates_no_pr=1
+ _cmux_clear_pr_for_panel
+ return 0
+ fi
+ if _cmux_pr_output_indicates_no_pull_request "$gh_error"; then
+ _cmux_clear_pr_for_panel
+ return 0
fi
- # `gh pr view` without an explicit branch can fail to resolve the
- # current worktree branch even when the branch has a PR. Fall back to
- # the explicit branch name before concluding there is no PR.
- err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)"
- [[ -n "$err_file" ]] || return 1
- explicit_branch_output="$(
- builtin cd "$repo_path" 2>/dev/null \
- && gh pr view "$branch" \
- "${gh_repo_args[@]}" \
- --json number,state,url \
- --jq '[.number, .state, .url] | @tsv' \
- 2>"$err_file"
- )"
- explicit_branch_status=$?
- if [[ -f "$err_file" ]]; then
- explicit_branch_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)"
- /bin/rm -f -- "$err_file" >/dev/null 2>&1 || true
- fi
-
- if (( explicit_branch_status == 0 )) && [[ -n "$explicit_branch_output" ]]; then
- gh_output="$explicit_branch_output"
- gh_status=0
- else
- if (( explicit_branch_status == 0 )) && [[ -z "$explicit_branch_output" ]]; then
- explicit_probe_indicates_no_pr=1
- elif _cmux_pr_output_indicates_no_pull_request "$explicit_branch_error"; then
- explicit_probe_indicates_no_pr=1
- fi
-
- if (( implicit_probe_indicates_no_pr )) && (( explicit_probe_indicates_no_pr )); then
- _cmux_clear_pr_for_panel
- return 0
- fi
-
- # Preserve the last-known PR badge when gh fails transiently, then retry
- # on the next background poll instead of clearing visible state.
- return 1
- fi
+ # Always scope PR detection to the exact current branch. Preserve the
+ # last-known PR badge when gh fails transiently, then retry on the next
+ # background poll instead of showing a mismatched PR.
+ return 1
fi
IFS=$'\t' read -r number state url <<< "$gh_output"
@@ -284,7 +249,8 @@ _cmux_report_pr_for_path() {
*) return 1 ;;
esac
- _cmux_send "report_pr $number $url $status_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
+ local quoted_branch="${branch//\"/\\\"}"
+ _cmux_send "report_pr $number $url $status_opt --branch=\"$quoted_branch\" --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
}
_cmux_child_pids() {
diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh
index 1bcf084f..3026ae95 100644
--- a/Resources/shell-integration/cmux-zsh-integration.zsh
+++ b/Resources/shell-integration/cmux-zsh-integration.zsh
@@ -312,8 +312,6 @@ _cmux_report_pr_for_path() {
[[ -n "$CMUX_PANEL_ID" ]] || return 0
local branch repo_slug="" gh_output="" gh_error="" err_file="" number state url status_opt="" gh_status
- local explicit_branch_output="" explicit_branch_error="" explicit_branch_status=0
- local implicit_probe_indicates_no_pr=0 explicit_probe_indicates_no_pr=0
local -a gh_repo_args
gh_repo_args=()
branch="$(git -C "$repo_path" branch --show-current 2>/dev/null)"
@@ -330,7 +328,7 @@ _cmux_report_pr_for_path() {
[[ -n "$err_file" ]] || return 1
gh_output="$(
builtin cd "$repo_path" 2>/dev/null \
- && gh pr view \
+ && gh pr view "$branch" \
"${gh_repo_args[@]}" \
--json number,state,url \
--jq '[.number, .state, .url] | @tsv' \
@@ -342,53 +340,20 @@ _cmux_report_pr_for_path() {
/bin/rm -f -- "$err_file" >/dev/null 2>&1 || true
fi
- if (( gh_status == 0 )) && [[ -n "$gh_output" ]]; then
- :
- else
+ if (( gh_status != 0 )) || [[ -z "$gh_output" ]]; then
if (( gh_status == 0 )) && [[ -z "$gh_output" ]]; then
- implicit_probe_indicates_no_pr=1
- elif _cmux_pr_output_indicates_no_pull_request "$gh_error"; then
- implicit_probe_indicates_no_pr=1
+ _cmux_clear_pr_for_panel
+ return 0
+ fi
+ if _cmux_pr_output_indicates_no_pull_request "$gh_error"; then
+ _cmux_clear_pr_for_panel
+ return 0
fi
- # `gh pr view` without an explicit branch can fail to resolve the
- # current worktree branch even when the branch has a PR. Fall back to
- # the explicit branch name before concluding there is no PR.
- err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)"
- [[ -n "$err_file" ]] || return 1
- explicit_branch_output="$(
- builtin cd "$repo_path" 2>/dev/null \
- && gh pr view "$branch" \
- "${gh_repo_args[@]}" \
- --json number,state,url \
- --jq '[.number, .state, .url] | @tsv' \
- 2>"$err_file"
- )"
- explicit_branch_status=$?
- if [[ -f "$err_file" ]]; then
- explicit_branch_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)"
- /bin/rm -f -- "$err_file" >/dev/null 2>&1 || true
- fi
-
- if (( explicit_branch_status == 0 )) && [[ -n "$explicit_branch_output" ]]; then
- gh_output="$explicit_branch_output"
- gh_status=0
- else
- if (( explicit_branch_status == 0 )) && [[ -z "$explicit_branch_output" ]]; then
- explicit_probe_indicates_no_pr=1
- elif _cmux_pr_output_indicates_no_pull_request "$explicit_branch_error"; then
- explicit_probe_indicates_no_pr=1
- fi
-
- if (( implicit_probe_indicates_no_pr )) && (( explicit_probe_indicates_no_pr )); then
- _cmux_clear_pr_for_panel
- return 0
- fi
-
- # Keep the last-known PR badge on transient gh failures (auth hiccups,
- # API lag after creation, or rate limiting) and retry on the next poll.
- return 1
- fi
+ # Always scope PR detection to the exact current branch. When gh fails
+ # transiently (auth hiccups, API lag, rate limiting), keep the last-known
+ # badge and retry on the next poll instead of showing a mismatched PR.
+ return 1
fi
local IFS=$'\t'
@@ -404,7 +369,8 @@ _cmux_report_pr_for_path() {
*) return 1 ;;
esac
- _cmux_send "report_pr $number $url $status_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
+ local quoted_branch="${branch//\"/\\\"}"
+ _cmux_send "report_pr $number $url $status_opt --branch=\"$quoted_branch\" --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
}
_cmux_child_pids() {
diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift
index 8242588b..fca38cd5 100644
--- a/Sources/ContentView.swift
+++ b/Sources/ContentView.swift
@@ -10882,7 +10882,7 @@ private struct TabItemView: View, Equatable {
.underline()
.lineLimit(1)
.truncationMode(.tail)
- Text(pullRequestStatusLabel(pullRequest.status))
+ Text(pullRequestStatusLabel(pullRequest.status, checks: pullRequest.checks))
.lineLimit(1)
Spacer(minLength: 0)
}
@@ -11578,6 +11578,7 @@ private struct TabItemView: View, Equatable {
let label: String
let url: URL
let status: SidebarPullRequestStatus
+ let checks: SidebarPullRequestChecksStatus?
}
private func pullRequestDisplays(orderedPanelIds: [UUID]) -> [PullRequestDisplay] {
@@ -11587,7 +11588,8 @@ private struct TabItemView: View, Equatable {
number: pullRequest.number,
label: pullRequest.label,
url: pullRequest.url,
- status: pullRequest.status
+ status: pullRequest.status,
+ checks: pullRequest.checks
)
}
}
@@ -11612,7 +11614,10 @@ private struct TabItemView: View, Equatable {
NSWorkspace.shared.open(url)
}
- private func pullRequestStatusLabel(_ status: SidebarPullRequestStatus) -> String {
+ private func pullRequestStatusLabel(
+ _ status: SidebarPullRequestStatus,
+ checks _: SidebarPullRequestChecksStatus?
+ ) -> String {
switch status {
case .open: return String(localized: "sidebar.pullRequest.statusOpen", defaultValue: "open")
case .merged: return String(localized: "sidebar.pullRequest.statusMerged", defaultValue: "merged")
diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift
index 783f0e7c..6df6abaf 100644
--- a/Sources/TabManager.swift
+++ b/Sources/TabManager.swift
@@ -647,10 +647,17 @@ fileprivate func cmuxVsyncIOSurfaceTimelineCallback(
@MainActor
class TabManager: ObservableObject {
+ private enum WorkspacePullRequestSnapshot: Equatable {
+ case unsupportedRepository
+ case notFound
+ case resolved(SidebarPullRequestState)
+ case transientFailure
+ }
+
private struct InitialWorkspaceGitMetadataSnapshot: Equatable {
let branch: String?
let isDirty: Bool
- let pullRequest: SidebarPullRequestState?
+ let pullRequest: WorkspacePullRequestSnapshot
}
private struct CommandResult {
@@ -661,6 +668,22 @@ class TabManager: ObservableObject {
let executionError: String?
}
+ private struct WorkspaceGitProbeKey: Hashable {
+ let workspaceId: UUID
+ let panelId: UUID
+ }
+
+ private struct GitHubPullRequestViewItem: Decodable {
+ let number: Int
+ let state: String
+ let url: String
+ }
+
+ private struct GitHubPullRequestCheckItem: Decodable {
+ let bucket: String?
+ let state: String?
+ }
+
/// The window that owns this TabManager. Set by AppDelegate.registerMainWindow().
/// Used to apply title updates to the correct window instead of NSApp.keyWindow.
weak var window: NSWindow?
@@ -674,7 +697,7 @@ class TabManager: ObservableObject {
/// Static so port ranges don't overlap across multiple windows (each window has its own TabManager).
private static var nextPortOrdinal: Int = 0
private static let initialWorkspaceGitProbeDelays: [TimeInterval] = [0, 0.5, 1.5, 3.0, 6.0, 10.0]
- private nonisolated static let initialWorkspacePullRequestProbeTimeout: TimeInterval = 5.0
+ private nonisolated static let workspacePullRequestProbeTimeout: TimeInterval = 5.0
@Published var selectedTabId: UUID? {
willSet {
#if DEBUG
@@ -761,8 +784,8 @@ class TabManager: ObservableObject {
label: "com.cmux.initial-workspace-git-probe",
qos: .utility
)
- private var initialWorkspaceGitProbeGenerationByWorkspace: [UUID: UUID] = [:]
- private var initialWorkspaceGitProbeTimersByWorkspace: [UUID: [DispatchSourceTimer]] = [:]
+ private var workspaceGitProbeGenerationByKey: [WorkspaceGitProbeKey: UUID] = [:]
+ private var workspaceGitProbeTimersByKey: [WorkspaceGitProbeKey: [DispatchSourceTimer]] = [:]
// Recent tab history for back/forward navigation (like browser history)
private var tabHistory: [UUID] = []
@@ -892,6 +915,33 @@ class TabManager: ObservableObject {
}
}
+ private func gitProbeDirectory(for workspace: Workspace, panelId: UUID) -> String? {
+ let rawDirectory = workspace.panelDirectories[panelId]
+ ?? (workspace.focusedPanelId == panelId ? workspace.currentDirectory : nil)
+ return rawDirectory.flatMap(normalizedWorkingDirectory)
+ }
+
+ private func scheduleWorkspaceGitMetadataRefreshIfPossible(
+ workspaceId: UUID,
+ panelId: UUID,
+ reason: String,
+ delays: [TimeInterval] = [0]
+ ) {
+ guard let workspace = tabs.first(where: { $0.id == workspaceId }),
+ workspace.panels[panelId] != nil,
+ let directory = gitProbeDirectory(for: workspace, panelId: panelId) else {
+ return
+ }
+
+ scheduleWorkspaceGitMetadataRefresh(
+ workspaceId: workspaceId,
+ panelId: panelId,
+ directory: directory,
+ delays: delays,
+ reason: reason
+ )
+ }
+
private func wireClosedBrowserTracking(for workspace: Workspace) {
workspace.onClosedBrowserPanel = { [weak self] snapshot in
self?.recentlyClosedBrowsers.push(snapshot)
@@ -1147,20 +1197,36 @@ class TabManager: ObservableObject {
workspaceId: UUID,
panelId: UUID,
directory: String
+ ) {
+ scheduleWorkspaceGitMetadataRefresh(
+ workspaceId: workspaceId,
+ panelId: panelId,
+ directory: directory,
+ delays: Self.initialWorkspaceGitProbeDelays,
+ reason: "initial"
+ )
+ }
+
+ private func scheduleWorkspaceGitMetadataRefresh(
+ workspaceId: UUID,
+ panelId: UUID,
+ directory: String,
+ delays: [TimeInterval],
+ reason: String
) {
let normalizedDirectory = normalizeDirectory(directory)
+ let key = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: panelId)
let generation = UUID()
- cancelInitialWorkspaceGitProbeTimers(workspaceId: workspaceId)
- initialWorkspaceGitProbeGenerationByWorkspace[workspaceId] = generation
+ cancelWorkspaceGitProbeTimers(for: key)
+ workspaceGitProbeGenerationByKey[key] = generation
#if DEBUG
dlog(
"workspace.gitProbe.schedule workspace=\(workspaceId.uuidString.prefix(5)) " +
- "panel=\(panelId.uuidString.prefix(5)) dir=\(normalizedDirectory)"
+ "panel=\(panelId.uuidString.prefix(5)) dir=\(normalizedDirectory) reason=\(reason)"
)
#endif
- let delays = Self.initialWorkspaceGitProbeDelays
var timers: [DispatchSourceTimer] = []
for (index, delay) in delays.enumerated() {
let isLastAttempt = index == delays.count - 1
@@ -1169,11 +1235,10 @@ class TabManager: ObservableObject {
timer.setEventHandler { [weak self] in
let snapshot = Self.initialWorkspaceGitMetadataSnapshot(for: normalizedDirectory)
Task { @MainActor [weak self] in
- self?.applyInitialWorkspaceGitMetadataSnapshot(
+ self?.applyWorkspaceGitMetadataSnapshot(
snapshot,
generation: generation,
- workspaceId: workspaceId,
- panelId: panelId,
+ probeKey: key,
expectedDirectory: normalizedDirectory,
isLastAttempt: isLastAttempt
)
@@ -1182,11 +1247,11 @@ class TabManager: ObservableObject {
timers.append(timer)
timer.resume()
}
- initialWorkspaceGitProbeTimersByWorkspace[workspaceId] = timers
+ workspaceGitProbeTimersByKey[key] = timers
}
- private func cancelInitialWorkspaceGitProbeTimers(workspaceId: UUID) {
- guard let timers = initialWorkspaceGitProbeTimersByWorkspace.removeValue(forKey: workspaceId) else {
+ private func cancelWorkspaceGitProbeTimers(for key: WorkspaceGitProbeKey) {
+ guard let timers = workspaceGitProbeTimersByKey.removeValue(forKey: key) else {
return
}
for timer in timers {
@@ -1195,95 +1260,139 @@ class TabManager: ObservableObject {
}
}
- private func clearInitialWorkspaceGitProbe(workspaceId: UUID) {
- initialWorkspaceGitProbeGenerationByWorkspace.removeValue(forKey: workspaceId)
- cancelInitialWorkspaceGitProbeTimers(workspaceId: workspaceId)
+ private func clearWorkspaceGitProbe(_ key: WorkspaceGitProbeKey) {
+ workspaceGitProbeGenerationByKey.removeValue(forKey: key)
+ cancelWorkspaceGitProbeTimers(for: key)
}
- private func applyInitialWorkspaceGitMetadataSnapshot(
+ private func clearWorkspaceGitProbes(workspaceId: UUID) {
+ let keys = Set(workspaceGitProbeGenerationByKey.keys.filter { $0.workspaceId == workspaceId })
+ .union(workspaceGitProbeTimersByKey.keys.filter { $0.workspaceId == workspaceId })
+ for key in keys {
+ clearWorkspaceGitProbe(key)
+ }
+ }
+
+ private func applyWorkspaceGitMetadataSnapshot(
_ snapshot: InitialWorkspaceGitMetadataSnapshot,
generation: UUID,
- workspaceId: UUID,
- panelId: UUID,
+ probeKey: WorkspaceGitProbeKey,
expectedDirectory: String,
isLastAttempt: Bool
) {
defer {
- if isLastAttempt,
- initialWorkspaceGitProbeGenerationByWorkspace[workspaceId] == generation {
- clearInitialWorkspaceGitProbe(workspaceId: workspaceId)
+ if shouldStopWorkspaceGitMetadataRefresh(snapshot) || isLastAttempt,
+ workspaceGitProbeGenerationByKey[probeKey] == generation {
+ clearWorkspaceGitProbe(probeKey)
}
}
- guard initialWorkspaceGitProbeGenerationByWorkspace[workspaceId] == generation else { return }
- guard let workspace = tabs.first(where: { $0.id == workspaceId }) else {
- clearInitialWorkspaceGitProbe(workspaceId: workspaceId)
+ guard workspaceGitProbeGenerationByKey[probeKey] == generation else { return }
+ guard let workspace = tabs.first(where: { $0.id == probeKey.workspaceId }) else {
+ clearWorkspaceGitProbe(probeKey)
return
}
- guard workspace.panels[panelId] != nil else {
- clearInitialWorkspaceGitProbe(workspaceId: workspaceId)
+ guard workspace.panels[probeKey.panelId] != nil else {
+ clearWorkspaceGitProbe(probeKey)
return
}
- let currentDirectory = normalizedWorkingDirectory(
- workspace.panelDirectories[panelId] ?? workspace.currentDirectory
- )
- if let currentDirectory, currentDirectory != expectedDirectory {
- clearInitialWorkspaceGitProbe(workspaceId: workspaceId)
+ guard let currentDirectory = gitProbeDirectory(for: workspace, panelId: probeKey.panelId) else {
+ clearWorkspaceGitProbe(probeKey)
+ return
+ }
+ if currentDirectory != expectedDirectory {
+ clearWorkspaceGitProbe(probeKey)
#if DEBUG
dlog(
- "workspace.gitProbe.skip workspace=\(workspaceId.uuidString.prefix(5)) " +
- "panel=\(panelId.uuidString.prefix(5)) reason=directoryChanged " +
+ "workspace.gitProbe.skip workspace=\(probeKey.workspaceId.uuidString.prefix(5)) " +
+ "panel=\(probeKey.panelId.uuidString.prefix(5)) reason=directoryChanged " +
"expected=\(expectedDirectory) current=\(currentDirectory)"
)
#endif
return
}
- workspace.updatePanelDirectory(panelId: panelId, directory: expectedDirectory)
+ workspace.updatePanelDirectory(panelId: probeKey.panelId, directory: expectedDirectory)
- let previousBranch = Self.normalizedBranchName(workspace.panelGitBranches[panelId]?.branch)
let nextBranch = snapshot.branch
if let nextBranch {
- workspace.updatePanelGitBranch(panelId: panelId, branch: nextBranch, isDirty: snapshot.isDirty)
+ workspace.updatePanelGitBranch(
+ panelId: probeKey.panelId,
+ branch: nextBranch,
+ isDirty: snapshot.isDirty
+ )
} else {
- workspace.clearPanelGitBranch(panelId: panelId)
+ workspace.clearPanelGitBranch(panelId: probeKey.panelId)
}
- if let pullRequest = snapshot.pullRequest {
+ switch snapshot.pullRequest {
+ case .resolved(let pullRequest):
workspace.updatePanelPullRequest(
- panelId: panelId,
+ panelId: probeKey.panelId,
number: pullRequest.number,
label: pullRequest.label,
url: pullRequest.url,
- status: pullRequest.status
+ status: pullRequest.status,
+ checks: pullRequest.checks
)
- } else if previousBranch != nextBranch || (nextBranch == nil && workspace.panelPullRequests[panelId] != nil) {
- workspace.clearPanelPullRequest(panelId: panelId)
+ case .notFound:
+ if workspace.panelPullRequests[probeKey.panelId] != nil {
+ workspace.clearPanelPullRequest(panelId: probeKey.panelId)
+ }
+ case .unsupportedRepository, .transientFailure:
+ break
}
#if DEBUG
let branchLabel = snapshot.branch ?? "none"
- let prLabel = snapshot.pullRequest.map { "#\($0.number):\($0.status.rawValue)" } ?? "none"
+ let prLabel: String = {
+ switch snapshot.pullRequest {
+ case .unsupportedRepository:
+ return "unsupported"
+ case .notFound:
+ return "none"
+ case .transientFailure:
+ return "transientFailure"
+ case .resolved(let pullRequest):
+ let checks = pullRequest.checks?.rawValue ?? "none"
+ return "#\(pullRequest.number):\(pullRequest.status.rawValue):\(checks)"
+ }
+ }()
dlog(
- "workspace.gitProbe.apply workspace=\(workspaceId.uuidString.prefix(5)) " +
- "panel=\(panelId.uuidString.prefix(5)) branch=\(branchLabel) dirty=\(snapshot.isDirty ? 1 : 0) " +
+ "workspace.gitProbe.apply workspace=\(probeKey.workspaceId.uuidString.prefix(5)) " +
+ "panel=\(probeKey.panelId.uuidString.prefix(5)) branch=\(branchLabel) dirty=\(snapshot.isDirty ? 1 : 0) " +
"pr=\(prLabel)"
)
#endif
}
+ private func shouldStopWorkspaceGitMetadataRefresh(
+ _ snapshot: InitialWorkspaceGitMetadataSnapshot
+ ) -> Bool {
+ switch snapshot.pullRequest {
+ case .transientFailure:
+ return false
+ case .unsupportedRepository, .notFound, .resolved:
+ return true
+ }
+ }
+
private nonisolated static func initialWorkspaceGitMetadataSnapshot(
for directory: String
) -> InitialWorkspaceGitMetadataSnapshot {
let branch = normalizedBranchName(runGitCommand(directory: directory, arguments: ["branch", "--show-current"]))
guard let branch else {
- return InitialWorkspaceGitMetadataSnapshot(branch: nil, isDirty: false, pullRequest: nil)
+ return InitialWorkspaceGitMetadataSnapshot(
+ branch: nil,
+ isDirty: false,
+ pullRequest: .notFound
+ )
}
let statusOutput = runGitCommand(directory: directory, arguments: ["status", "--porcelain", "-uno"])
let isDirty = !(statusOutput?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
- let pullRequest = initialWorkspacePullRequestSnapshot(directory: directory, branch: branch)
+ let pullRequest = workspacePullRequestSnapshot(directory: directory, branch: branch)
return InitialWorkspaceGitMetadataSnapshot(branch: branch, isDirty: isDirty, pullRequest: pullRequest)
}
@@ -1295,34 +1404,42 @@ class TabManager: ObservableObject {
)
}
- private nonisolated static func initialWorkspacePullRequestSnapshot(
+ private nonisolated static func workspacePullRequestSnapshot(
directory: String,
branch: String
- ) -> SidebarPullRequestState? {
- let repoSlug = githubRepositorySlug(directory: directory)
- let repoArguments = repoSlug.map { ["--repo", $0] } ?? []
+ ) -> WorkspacePullRequestSnapshot {
+ guard let repoSlug = githubRepositorySlug(directory: directory) else {
+ return .unsupportedRepository
+ }
+
let result = runCommandResult(
directory: directory,
executable: "gh",
arguments: [
"pr", "view", branch,
- ] + repoArguments + [
+ "--repo", repoSlug,
"--json", "number,state,url",
- "--jq", "[.number, .state, .url] | @tsv",
],
- timeout: initialWorkspacePullRequestProbeTimeout
+ timeout: workspacePullRequestProbeTimeout
)
- guard let result else { return nil }
- guard let output = result.stdout,
- result.exitStatus == 0,
- !output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+ guard let result else {
+#if DEBUG
+ dlog(
+ "workspace.gitProbe.pr.fail dir=\(directory) branch=\(branch) " +
+ "repo=\(repoSlug) status=nil"
+ )
+#endif
+ return .transientFailure
+ }
+
+ guard !result.timedOut,
+ result.executionError == nil,
+ let exitStatus = result.exitStatus else {
#if DEBUG
let statusText: String
if result.timedOut {
statusText = "timeout"
- } else if let exitStatus = result.exitStatus {
- statusText = "exit=\(exitStatus)"
} else if let executionError = result.executionError {
statusText = "error=\(executionError)"
} else {
@@ -1331,47 +1448,188 @@ class TabManager: ObservableObject {
let stderr = debugLogSnippet(result.stderr) ?? "none"
dlog(
"workspace.gitProbe.pr.fail dir=\(directory) branch=\(branch) " +
- "repo=\(repoSlug ?? "none") status=\(statusText) stderr=\(stderr)"
+ "repo=\(repoSlug) status=\(statusText) stderr=\(stderr)"
)
#endif
- return nil
+ return .transientFailure
}
- let trimmedOutput = output.trimmingCharacters(in: .whitespacesAndNewlines)
- let fields = trimmedOutput
- .trimmingCharacters(in: .whitespacesAndNewlines)
- .split(separator: "\t", maxSplits: 2, omittingEmptySubsequences: false)
- guard fields.count == 3,
- let number = Int(fields[0]),
- let url = URL(string: String(fields[2])) else {
+ if exitStatus != 0 {
+ let stderr = result.stderr ?? ""
+ if prErrorIndicatesNoPullRequest(stderr) {
+#if DEBUG
+ dlog(
+ "workspace.gitProbe.pr.none dir=\(directory) branch=\(branch) " +
+ "repo=\(repoSlug) stderr=\(debugLogSnippet(stderr) ?? "none")"
+ )
+#endif
+ return .notFound
+ }
+#if DEBUG
+ dlog(
+ "workspace.gitProbe.pr.fail dir=\(directory) branch=\(branch) " +
+ "repo=\(repoSlug) status=exit=\(exitStatus) stderr=\(debugLogSnippet(stderr) ?? "none")"
+ )
+#endif
+ return .transientFailure
+ }
+
+ let output = result.stdout ?? ""
+ guard !output.isEmpty,
+ let pullRequest = decodeJSON(GitHubPullRequestViewItem.self, from: output) else {
#if DEBUG
dlog(
"workspace.gitProbe.pr.parseFail dir=\(directory) branch=\(branch) " +
- "repo=\(repoSlug ?? "none") output=\(debugLogSnippet(trimmedOutput) ?? "none")"
+ "repo=\(repoSlug) output=\(debugLogSnippet(output) ?? "none")"
)
#endif
- return nil
+ return .transientFailure
}
- let status: SidebarPullRequestStatus
- switch fields[1].uppercased() {
- case "OPEN":
- status = .open
- case "MERGED":
- status = .merged
- case "CLOSED":
- status = .closed
- default:
- return nil
+ guard let status = pullRequestStatus(from: pullRequest.state),
+ let url = URL(string: pullRequest.url) else {
+#if DEBUG
+ dlog(
+ "workspace.gitProbe.pr.parseFail dir=\(directory) branch=\(branch) " +
+ "repo=\(repoSlug) output=\(debugLogSnippet(output) ?? "none")"
+ )
+#endif
+ return .transientFailure
}
+ let checks = status == .open
+ ? pullRequestChecksStatus(number: pullRequest.number, directory: directory, repoSlug: repoSlug)
+ : nil
+
#if DEBUG
dlog(
"workspace.gitProbe.pr.success dir=\(directory) branch=\(branch) " +
- "repo=\(repoSlug ?? "none") number=\(number) state=\(status.rawValue)"
+ "repo=\(repoSlug) number=\(pullRequest.number) state=\(status.rawValue) checks=\(checks?.rawValue ?? "none")"
)
#endif
- return SidebarPullRequestState(number: number, label: "PR", url: url, status: status)
+ return .resolved(
+ SidebarPullRequestState(
+ number: pullRequest.number,
+ label: "PR",
+ url: url,
+ status: status,
+ branch: branch,
+ checks: checks
+ )
+ )
+ }
+
+ private nonisolated static func pullRequestChecksStatus(
+ number: Int,
+ directory: String,
+ repoSlug: String
+ ) -> SidebarPullRequestChecksStatus? {
+ let result = runCommandResult(
+ directory: directory,
+ executable: "gh",
+ arguments: [
+ "pr", "checks", String(number),
+ "--repo", repoSlug,
+ "--json", "bucket,state"
+ ],
+ timeout: workspacePullRequestProbeTimeout
+ )
+
+ guard let result,
+ !result.timedOut,
+ result.executionError == nil,
+ let output = result.stdout,
+ let exitStatus = result.exitStatus,
+ exitStatus == 0 || exitStatus == 8,
+ let checks = decodeJSON([GitHubPullRequestCheckItem].self, from: output) else {
+ return nil
+ }
+
+ var sawPending = false
+ var sawPass = false
+
+ for check in checks {
+ let bucket = check.bucket?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ let state = check.state?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+
+ if isFailingCheckState(bucket: bucket, state: state) {
+ return .fail
+ }
+ if isPendingCheckState(bucket: bucket, state: state) {
+ sawPending = true
+ continue
+ }
+ if isPassingCheckState(bucket: bucket, state: state) {
+ sawPass = true
+ }
+ }
+
+ if sawPending {
+ return .pending
+ }
+ if sawPass {
+ return .pass
+ }
+ return nil
+ }
+
+ private nonisolated static func pullRequestStatus(
+ from rawState: String
+ ) -> SidebarPullRequestStatus? {
+ switch rawState.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() {
+ case "OPEN":
+ return .open
+ case "MERGED":
+ return .merged
+ case "CLOSED":
+ return .closed
+ default:
+ return nil
+ }
+ }
+
+ private nonisolated static func decodeJSON(_ type: T.Type, from text: String) -> T? {
+ guard let data = text.data(using: .utf8) else { return nil }
+ return try? JSONDecoder().decode(T.self, from: data)
+ }
+
+ private nonisolated static func prErrorIndicatesNoPullRequest(_ text: String?) -> Bool {
+ let normalized = text?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .lowercased() ?? ""
+ guard !normalized.isEmpty else { return false }
+ return normalized.contains("no pull requests found")
+ || normalized.contains("no pull request found")
+ || normalized.contains("no pull requests associated")
+ || normalized.contains("no pull request associated")
+ }
+
+ private nonisolated static func isFailingCheckState(bucket: String?, state: String?) -> Bool {
+ switch bucket ?? state ?? "" {
+ case "fail", "failure", "failed", "error", "timed_out", "timedout",
+ "cancel", "cancelled", "canceled", "action_required", "startup_failure":
+ return true
+ default:
+ return false
+ }
+ }
+
+ private nonisolated static func isPendingCheckState(bucket: String?, state: String?) -> Bool {
+ switch bucket ?? state ?? "" {
+ case "pending", "queued", "in_progress", "requested", "waiting", "expected":
+ return true
+ default:
+ return false
+ }
+ }
+
+ private nonisolated static func isPassingCheckState(bucket: String?, state: String?) -> Bool {
+ switch bucket ?? state ?? "" {
+ case "pass", "success", "successful", "completed", "neutral", "skipping", "skipped":
+ return true
+ default:
+ return false
+ }
}
private nonisolated static func runCommand(
@@ -1765,8 +2023,49 @@ class TabManager: ObservableObject {
func updateSurfaceDirectory(tabId: UUID, surfaceId: UUID, directory: String) {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
+ let previousDirectory = gitProbeDirectory(for: tab, panelId: surfaceId)
let normalized = normalizeDirectory(directory)
tab.updatePanelDirectory(panelId: surfaceId, directory: normalized)
+ let nextDirectory = normalizedWorkingDirectory(normalized)
+ if previousDirectory != nextDirectory {
+ scheduleWorkspaceGitMetadataRefreshIfPossible(
+ workspaceId: tabId,
+ panelId: surfaceId,
+ reason: "directoryChange"
+ )
+ }
+ }
+
+ func updateSurfaceGitBranch(
+ tabId: UUID,
+ surfaceId: UUID,
+ branch: String,
+ isDirty: Bool
+ ) {
+ guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
+ let current = tab.panelGitBranches[surfaceId]
+ let normalizedBranch = Self.normalizedBranchName(branch) ?? branch
+ guard current?.branch != normalizedBranch || current?.isDirty != isDirty else { return }
+ tab.updatePanelGitBranch(panelId: surfaceId, branch: normalizedBranch, isDirty: isDirty)
+ scheduleWorkspaceGitMetadataRefreshIfPossible(
+ workspaceId: tabId,
+ panelId: surfaceId,
+ reason: "branchChange"
+ )
+ }
+
+ func clearSurfaceGitBranch(tabId: UUID, surfaceId: UUID) {
+ guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
+ let hadBranch = tab.panelGitBranches[surfaceId] != nil
+ let hadPullRequest = tab.panelPullRequests[surfaceId] != nil
+ guard hadBranch || hadPullRequest else { return }
+ tab.clearPanelGitBranch(panelId: surfaceId)
+ tab.clearPanelPullRequest(panelId: surfaceId)
+ scheduleWorkspaceGitMetadataRefreshIfPossible(
+ workspaceId: tabId,
+ panelId: surfaceId,
+ reason: "branchCleared"
+ )
}
func updateSurfaceShellActivity(
@@ -1792,7 +2091,7 @@ class TabManager: ObservableObject {
func closeWorkspace(_ workspace: Workspace) {
guard tabs.count > 1 else { return }
sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1])
- clearInitialWorkspaceGitProbe(workspaceId: workspace.id)
+ clearWorkspaceGitProbes(workspaceId: workspace.id)
sidebarSelectedWorkspaceIds.remove(workspace.id)
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id)
@@ -1819,7 +2118,7 @@ class TabManager: ObservableObject {
@discardableResult
func detachWorkspace(tabId: UUID) -> Workspace? {
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return nil }
- clearInitialWorkspaceGitProbe(workspaceId: tabId)
+ clearWorkspaceGitProbes(workspaceId: tabId)
sidebarSelectedWorkspaceIds.remove(tabId)
let removed = tabs.remove(at: index)
@@ -4607,8 +4906,10 @@ extension TabManager {
for tab in tabs {
unwireClosedBrowserTracking(for: tab)
}
- for workspaceId in Array(initialWorkspaceGitProbeGenerationByWorkspace.keys) {
- clearInitialWorkspaceGitProbe(workspaceId: workspaceId)
+ let existingProbeKeys = Set(workspaceGitProbeGenerationByKey.keys)
+ .union(workspaceGitProbeTimersByKey.keys)
+ for key in existingProbeKeys {
+ clearWorkspaceGitProbe(key)
}
// Clear non-@Published state without touching tabs/selectedTabId yet.
@@ -4667,19 +4968,17 @@ extension TabManager {
tabs = newTabs
selectedTabId = newSelectedId
for workspace in newTabs {
- guard let terminalPanel = workspace.focusedTerminalPanel ?? workspace.panels.values
- .compactMap({ $0 as? TerminalPanel })
- .first,
- let directory = normalizedWorkingDirectory(
- workspace.panelDirectories[terminalPanel.id] ?? workspace.currentDirectory
- ) else {
- continue
+ let terminalPanels = workspace.panels.values.compactMap { $0 as? TerminalPanel }
+ for terminalPanel in terminalPanels {
+ guard let directory = gitProbeDirectory(for: workspace, panelId: terminalPanel.id) else {
+ continue
+ }
+ scheduleInitialWorkspaceGitMetadataRefresh(
+ workspaceId: workspace.id,
+ panelId: terminalPanel.id,
+ directory: directory
+ )
}
- scheduleInitialWorkspaceGitMetadataRefresh(
- workspaceId: workspace.id,
- panelId: terminalPanel.id,
- directory: directory
- )
}
if let selectedTabId {
diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift
index de1e96f3..6227cbaf 100644
--- a/Sources/TerminalController.swift
+++ b/Sources/TerminalController.swift
@@ -387,10 +387,42 @@ class TerminalController {
number: Int,
label: String,
url: URL,
- status: SidebarPullRequestStatus
+ status: SidebarPullRequestStatus,
+ branch: String?,
+ checks: SidebarPullRequestChecksStatus?
) -> Bool {
guard let current else { return true }
- return current.number != number || current.label != label || current.url != url || current.status != status
+ let normalizedBranch = branch?.trimmingCharacters(in: .whitespacesAndNewlines)
+ let effectiveBranch: String? = {
+ if let normalizedBranch, !normalizedBranch.isEmpty {
+ return normalizedBranch
+ }
+ guard current.number == number,
+ current.label == label,
+ current.url == url,
+ current.status == status else {
+ return nil
+ }
+ return current.branch
+ }()
+ let effectiveChecks: SidebarPullRequestChecksStatus? = {
+ if let checks {
+ return checks
+ }
+ guard current.number == number,
+ current.label == label,
+ current.url == url,
+ current.status == status else {
+ return nil
+ }
+ return current.checks
+ }()
+ return current.number != number
+ || current.label != label
+ || current.url != url
+ || current.status != status
+ || current.branch != effectiveBranch
+ || current.checks != effectiveChecks
}
nonisolated static func shouldReplacePorts(current: [Int]?, next: [Int]) -> Bool {
@@ -10844,8 +10876,8 @@ class TerminalController {
clear_progress [--tab=X] - Clear progress bar
report_git_branch [--status=dirty] [--tab=X] [--panel=Y] - Report git branch
clear_git_branch [--tab=X] [--panel=Y] - Clear git branch
- report_pr [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y] - Report pull request / review item
- report_review [--label=MR] [--state=open|merged|closed] [--tab=X] [--panel=Y] - Alias for provider-specific review item
+ report_pr [--label=PR] [--state=open|merged|closed] [--branch=] [--checks=pass|fail|pending] [--tab=X] [--panel=Y] - Report pull request / review item
+ report_review [--label=MR] [--state=open|merged|closed] [--checks=pass|fail|pending] [--tab=X] [--panel=Y] - Alias for provider-specific review item
clear_pr [--tab=X] [--panel=Y] - Clear pull request
report_ports [port2...] [--tab=X] [--panel=Y] - Report listening ports
report_tty [--tab=X] [--panel=Y] - Register TTY for batched port scanning
@@ -14492,7 +14524,12 @@ class TerminalController {
let validSurfaceIds = Set(tab.panels.keys)
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
guard validSurfaceIds.contains(scope.panelId) else { return }
- tab.updatePanelGitBranch(panelId: scope.panelId, branch: branch, isDirty: isDirty)
+ tabManager.updateSurfaceGitBranch(
+ tabId: scope.workspaceId,
+ surfaceId: scope.panelId,
+ branch: branch,
+ isDirty: isDirty
+ )
}
return "OK"
}
@@ -14523,7 +14560,7 @@ class TerminalController {
let validSurfaceIds = Set(tab.panels.keys)
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
guard validSurfaceIds.contains(scope.panelId) else { return }
- tab.clearPanelGitBranch(panelId: scope.panelId)
+ tabManager.clearSurfaceGitBranch(tabId: scope.workspaceId, surfaceId: scope.panelId)
}
return "OK"
}
@@ -14541,7 +14578,7 @@ class TerminalController {
private func reportPullRequest(_ args: String) -> String {
let parsed = parseOptions(args)
guard parsed.positional.count >= 2 else {
- return "ERROR: Missing pull request number or URL — usage: report_pr [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y]"
+ return "ERROR: Missing pull request number or URL — usage: report_pr [--label=PR] [--state=open|merged|closed] [--branch=] [--checks=pass|fail|pending] [--tab=X] [--panel=Y]"
}
let rawNumber = parsed.positional[0].trimmingCharacters(in: .whitespacesAndNewlines)
@@ -14561,10 +14598,21 @@ class TerminalController {
guard let status = SidebarPullRequestStatus(rawValue: statusRaw) else {
return "ERROR: Invalid pull request state '\(statusRaw)' — use: open, merged, closed"
}
+ let branch = normalizedOptionValue(parsed.options["branch"])
+
+ let checks: SidebarPullRequestChecksStatus?
+ if let rawChecks = normalizedOptionValue(parsed.options["checks"]) {
+ guard let parsedChecks = SidebarPullRequestChecksStatus(rawValue: rawChecks.lowercased()) else {
+ return "ERROR: Invalid pull request checks '\(rawChecks)' — use: pass, fail, pending"
+ }
+ checks = parsedChecks
+ } else {
+ checks = nil
+ }
let labelRaw = normalizedOptionValue(parsed.options["label"]) ?? "PR"
guard !labelRaw.isEmpty else {
- return "ERROR: Invalid review label — usage: report_pr [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y]"
+ return "ERROR: Invalid review label — usage: report_pr [--label=PR] [--state=open|merged|closed] [--branch=] [--checks=pass|fail|pending] [--tab=X] [--panel=Y]"
}
let label = String(labelRaw.prefix(16))
@@ -14573,14 +14621,16 @@ class TerminalController {
return schedulePanelMetadataMutation(
args: args,
options: parsed.options,
- missingPanelUsage: "report_pr [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y]"
+ missingPanelUsage: "report_pr [--label=PR] [--state=open|merged|closed] [--branch=] [--checks=pass|fail|pending] [--tab=X] [--panel=Y]"
) { tab, surfaceId in
guard Self.shouldReplacePullRequest(
current: tab.panelPullRequests[surfaceId],
number: number,
label: label,
url: url,
- status: status
+ status: status,
+ branch: branch,
+ checks: checks
) else {
return
}
@@ -14590,7 +14640,9 @@ class TerminalController {
number: number,
label: label,
url: url,
- status: status
+ status: status,
+ branch: branch,
+ checks: checks
)
}
}
@@ -14958,12 +15010,14 @@ class TerminalController {
lines.append("git_branch=none")
}
- if let pr = tab.pullRequest {
+ if let pr = tab.sidebarPullRequestsInDisplayOrder().first {
lines.append("pr=#\(pr.number) \(pr.status.rawValue) \(pr.url.absoluteString)")
lines.append("pr_label=\(pr.label)")
+ lines.append("pr_checks=\(pr.checks?.rawValue ?? "none")")
} else {
lines.append("pr=none")
lines.append("pr_label=none")
+ lines.append("pr_checks=none")
}
if tab.listeningPorts.isEmpty {
diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift
index 444f7170..4557f122 100644
--- a/Sources/Workspace.swift
+++ b/Sources/Workspace.swift
@@ -4506,11 +4506,41 @@ enum SidebarPullRequestStatus: String {
case closed
}
+enum SidebarPullRequestChecksStatus: String {
+ case pass
+ case fail
+ case pending
+}
+
+private func normalizedSidebarBranchName(_ branch: String?) -> String? {
+ guard let branch else { return nil }
+ let trimmed = branch.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.isEmpty ? nil : trimmed
+}
+
struct SidebarPullRequestState: Equatable {
let number: Int
let label: String
let url: URL
let status: SidebarPullRequestStatus
+ let branch: String?
+ let checks: SidebarPullRequestChecksStatus?
+
+ init(
+ number: Int,
+ label: String,
+ url: URL,
+ status: SidebarPullRequestStatus,
+ branch: String? = nil,
+ checks: SidebarPullRequestChecksStatus? = nil
+ ) {
+ self.number = number
+ self.label = label
+ self.url = url
+ self.status = status
+ self.branch = normalizedSidebarBranchName(branch)
+ self.checks = checks
+ }
}
enum SidebarBranchOrdering {
@@ -4606,6 +4636,15 @@ enum SidebarBranchOrdering {
}
}
+ func checksPriority(_ checks: SidebarPullRequestChecksStatus?) -> Int {
+ switch checks {
+ case .fail: return 3
+ case .pending: return 2
+ case .pass: return 1
+ case nil: return 0
+ }
+ }
+
func normalizedReviewURLKey(for url: URL) -> String {
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return url.absoluteString
@@ -4642,6 +4681,9 @@ enum SidebarBranchOrdering {
guard let existing = pullRequestsByKey[key] else { continue }
if statusPriority(state.status) > statusPriority(existing.status) {
pullRequestsByKey[key] = state
+ } else if state.status == existing.status,
+ checksPriority(state.checks) > checksPriority(existing.checks) {
+ pullRequestsByKey[key] = state
}
}
@@ -5671,9 +5713,16 @@ final class Workspace: Identifiable, ObservableObject {
func updatePanelGitBranch(panelId: UUID, branch: String, isDirty: Bool) {
let state = SidebarGitBranchState(branch: branch, isDirty: isDirty)
let existing = panelGitBranches[panelId]
+ let branchChanged = existing?.branch != nil && existing?.branch != branch
if existing?.branch != branch || existing?.isDirty != isDirty {
panelGitBranches[panelId] = state
}
+ if branchChanged {
+ panelPullRequests.removeValue(forKey: panelId)
+ if panelId == focusedPanelId {
+ pullRequest = nil
+ }
+ }
if panelId == focusedPanelId {
gitBranch = state
}
@@ -5681,8 +5730,10 @@ final class Workspace: Identifiable, ObservableObject {
func clearPanelGitBranch(panelId: UUID) {
panelGitBranches.removeValue(forKey: panelId)
+ panelPullRequests.removeValue(forKey: panelId)
if panelId == focusedPanelId {
gitBranch = nil
+ pullRequest = nil
}
}
@@ -5691,10 +5742,50 @@ final class Workspace: Identifiable, ObservableObject {
number: Int,
label: String,
url: URL,
- status: SidebarPullRequestStatus
+ status: SidebarPullRequestStatus,
+ branch: String? = nil,
+ checks: SidebarPullRequestChecksStatus? = nil
) {
- let state = SidebarPullRequestState(number: number, label: label, url: url, status: status)
let existing = panelPullRequests[panelId]
+ let normalizedBranch = normalizedSidebarBranchName(branch)
+ let currentPanelBranch = normalizedSidebarBranchName(panelGitBranches[panelId]?.branch)
+ let resolvedBranch: String? = {
+ if let normalizedBranch {
+ return normalizedBranch
+ }
+ if let currentPanelBranch {
+ return currentPanelBranch
+ }
+ guard let existing,
+ existing.number == number,
+ existing.label == label,
+ existing.url == url,
+ existing.status == status else {
+ return nil
+ }
+ return existing.branch
+ }()
+ let resolvedChecks: SidebarPullRequestChecksStatus? = {
+ if let checks {
+ return checks
+ }
+ guard let existing,
+ existing.number == number,
+ existing.label == label,
+ existing.url == url,
+ existing.status == status else {
+ return nil
+ }
+ return existing.checks
+ }()
+ let state = SidebarPullRequestState(
+ number: number,
+ label: label,
+ url: url,
+ status: status,
+ branch: resolvedBranch,
+ checks: resolvedChecks
+ )
if existing != state {
panelPullRequests[panelId] = state
}
@@ -5873,10 +5964,16 @@ final class Workspace: Identifiable, ObservableObject {
}
func sidebarPullRequestsInDisplayOrder(orderedPanelIds: [UUID]) -> [SidebarPullRequestState] {
- SidebarBranchOrdering.orderedUniquePullRequests(
+ let validPanelPullRequests = panelPullRequests.filter { panelId, state in
+ guard let pullRequestBranch = normalizedSidebarBranchName(state.branch) else {
+ return true
+ }
+ return normalizedSidebarBranchName(panelGitBranches[panelId]?.branch) == pullRequestBranch
+ }
+ return SidebarBranchOrdering.orderedUniquePullRequests(
orderedPanelIds: orderedPanelIds,
- panelPullRequests: panelPullRequests,
- fallbackPullRequest: pullRequest
+ panelPullRequests: validPanelPullRequests,
+ fallbackPullRequest: nil
)
}
diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
index 9f0d08f9..9f343720 100644
--- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
+++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
@@ -7294,6 +7294,40 @@ final class WorkspacePanelGitBranchTests: XCTestCase {
XCTAssertEqual(ordered.map(\.isDirty), [false, true])
}
+ @MainActor
+ func testSidebarPullRequestsTrackFocusedPanelOnly() {
+ let workspace = Workspace()
+ guard let firstPanelId = workspace.focusedPanelId,
+ let paneId = workspace.paneId(forPanelId: firstPanelId),
+ let secondPanel = workspace.newTerminalSurface(inPane: paneId, focus: false) else {
+ XCTFail("Expected focused panel and a second panel")
+ return
+ }
+
+ workspace.updatePanelGitBranch(panelId: firstPanelId, branch: "main", isDirty: false)
+ workspace.updatePanelGitBranch(panelId: secondPanel.id, branch: "feature/sidebar-pr", isDirty: false)
+ workspace.updatePanelPullRequest(
+ panelId: secondPanel.id,
+ number: 1629,
+ label: "PR",
+ url: URL(string: "https://github.com/manaflow-ai/cmux/pull/1629")!,
+ status: .open
+ )
+
+ XCTAssertNil(workspace.pullRequest)
+ XCTAssertTrue(
+ workspace.sidebarPullRequestsInDisplayOrder().isEmpty,
+ "Expected background panel PRs to stay hidden while the focused panel has no PR"
+ )
+
+ workspace.focusPanel(secondPanel.id)
+
+ XCTAssertEqual(
+ workspace.sidebarPullRequestsInDisplayOrder().map(\.number),
+ [1629]
+ )
+ }
+
func testSidebarOrderingUsesPaneOrderThenTabOrderWithBranchDeduping() {
let workspace = Workspace()
guard let leftFirstPanelId = workspace.focusedPanelId,
@@ -7624,6 +7658,62 @@ final class SidebarBranchOrderingTests: XCTestCase {
)
}
+ func testOrderedUniquePullRequestsPrefersEntryWithChecksWhenStatusesMatch() {
+ let first = UUID()
+ let second = UUID()
+
+ let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests(
+ orderedPanelIds: [first, second],
+ panelPullRequests: [
+ first: pullRequestState(
+ number: 42,
+ label: "PR",
+ url: "https://github.com/manaflow-ai/cmux/pull/42",
+ status: .open
+ ),
+ second: pullRequestState(
+ number: 42,
+ label: "PR",
+ url: "https://github.com/manaflow-ai/cmux/pull/42",
+ status: .open,
+ checks: .pass
+ )
+ ],
+ fallbackPullRequest: nil
+ )
+
+ XCTAssertEqual(pullRequests.count, 1)
+ XCTAssertEqual(pullRequests.first?.checks, .pass)
+ }
+
+ @MainActor
+ func testUpdatePanelPullRequestPreservesExistingChecksWhenUpdateOmitsThem() {
+ let workspace = Workspace(title: "Tests", workingDirectory: FileManager.default.currentDirectoryPath, portOrdinal: 0)
+ guard let panelId = workspace.focusedPanelId else {
+ XCTFail("Expected focused panel for new workspace")
+ return
+ }
+
+ workspace.updatePanelPullRequest(
+ panelId: panelId,
+ number: 42,
+ label: "PR",
+ url: URL(string: "https://github.com/manaflow-ai/cmux/pull/42")!,
+ status: .open,
+ checks: .pass
+ )
+ workspace.updatePanelPullRequest(
+ panelId: panelId,
+ number: 42,
+ label: "PR",
+ url: URL(string: "https://github.com/manaflow-ai/cmux/pull/42")!,
+ status: .open
+ )
+
+ XCTAssertEqual(workspace.panelPullRequests[panelId]?.checks, .pass)
+ XCTAssertEqual(workspace.pullRequest?.checks, .pass)
+ }
+
func testOrderedUniquePullRequestsUsesFallbackWhenNoPanelPullRequestsExist() {
let fallback = pullRequestState(
number: 11,
@@ -7640,17 +7730,66 @@ final class SidebarBranchOrderingTests: XCTestCase {
XCTAssertEqual(pullRequests, [fallback])
}
+ @MainActor
+ func testUpdatePanelGitBranchClearsFocusedPullRequestWhenBranchChanges() {
+ let workspace = Workspace(title: "Tests", workingDirectory: FileManager.default.currentDirectoryPath, portOrdinal: 0)
+ guard let panelId = workspace.focusedPanelId else {
+ XCTFail("Expected focused panel for new workspace")
+ return
+ }
+
+ workspace.updatePanelGitBranch(panelId: panelId, branch: "feature/sidebar-pr", isDirty: false)
+ workspace.updatePanelPullRequest(
+ panelId: panelId,
+ number: 1629,
+ label: "PR",
+ url: URL(string: "https://github.com/manaflow-ai/cmux/pull/1629")!,
+ status: .open
+ )
+
+ workspace.updatePanelGitBranch(panelId: panelId, branch: "main", isDirty: false)
+
+ XCTAssertNil(workspace.pullRequest)
+ XCTAssertNil(workspace.panelPullRequests[panelId])
+ XCTAssertTrue(workspace.sidebarPullRequestsInDisplayOrder().isEmpty)
+ }
+
+ @MainActor
+ func testSidebarPullRequestsHideBranchMismatches() {
+ let workspace = Workspace(title: "Tests", workingDirectory: FileManager.default.currentDirectoryPath, portOrdinal: 0)
+ guard let panelId = workspace.focusedPanelId else {
+ XCTFail("Expected focused panel for new workspace")
+ return
+ }
+
+ workspace.updatePanelGitBranch(panelId: panelId, branch: "main", isDirty: false)
+ workspace.updatePanelPullRequest(
+ panelId: panelId,
+ number: 1629,
+ label: "PR",
+ url: URL(string: "https://github.com/manaflow-ai/cmux/pull/1629")!,
+ status: .open,
+ branch: "feature/sidebar-pr"
+ )
+
+ XCTAssertTrue(workspace.sidebarPullRequestsInDisplayOrder().isEmpty)
+ }
+
private func pullRequestState(
number: Int,
label: String,
url: String,
- status: SidebarPullRequestStatus
+ status: SidebarPullRequestStatus,
+ branch: String? = nil,
+ checks: SidebarPullRequestChecksStatus? = nil
) -> SidebarPullRequestState {
SidebarPullRequestState(
number: number,
label: label,
url: URL(string: url)!,
- status: status
+ status: status,
+ branch: branch,
+ checks: checks
)
}
}
diff --git a/cmuxTests/WorkspacePullRequestSidebarTests.swift b/cmuxTests/WorkspacePullRequestSidebarTests.swift
new file mode 100644
index 00000000..e3cc26e1
--- /dev/null
+++ b/cmuxTests/WorkspacePullRequestSidebarTests.swift
@@ -0,0 +1,82 @@
+import XCTest
+
+#if canImport(cmux_DEV)
+@testable import cmux_DEV
+#elseif canImport(cmux)
+@testable import cmux
+#endif
+
+@MainActor
+final class WorkspacePullRequestSidebarTests: XCTestCase {
+ func testSidebarPullRequestsIgnoreStaleWorkspaceLevelCacheWithoutPanelState() throws {
+ let workspace = Workspace(title: "Test")
+ let panelId = UUID()
+ let staleURL = try XCTUnwrap(URL(string: "https://github.com/manaflow-ai/cmux/pull/1640"))
+
+ workspace.pullRequest = SidebarPullRequestState(
+ number: 1640,
+ label: "PR",
+ url: staleURL,
+ status: .open,
+ branch: "main"
+ )
+ workspace.gitBranch = SidebarGitBranchState(branch: "main", isDirty: false)
+
+ XCTAssertEqual(workspace.sidebarPullRequestsInDisplayOrder(orderedPanelIds: [panelId]), [])
+ }
+
+ func testSidebarPullRequestsFilterBranchMismatchPerPanel() throws {
+ let workspace = Workspace(title: "Test")
+ let panelId = UUID()
+ let staleURL = try XCTUnwrap(URL(string: "https://github.com/manaflow-ai/cmux/pull/1640"))
+
+ workspace.panelGitBranches[panelId] = SidebarGitBranchState(branch: "main", isDirty: false)
+ workspace.panelPullRequests[panelId] = SidebarPullRequestState(
+ number: 1640,
+ label: "PR",
+ url: staleURL,
+ status: .open,
+ branch: "feature/old"
+ )
+
+ XCTAssertEqual(workspace.sidebarPullRequestsInDisplayOrder(orderedPanelIds: [panelId]), [])
+ }
+
+ func testSidebarPullRequestsPreferBestStateAcrossPanels() throws {
+ let workspace = Workspace(title: "Test")
+ let firstPanelId = UUID()
+ let secondPanelId = UUID()
+ let url = try XCTUnwrap(URL(string: "https://github.com/manaflow-ai/cmux/pull/1640"))
+
+ workspace.panelGitBranches[firstPanelId] = SidebarGitBranchState(branch: "feature/work", isDirty: false)
+ workspace.panelGitBranches[secondPanelId] = SidebarGitBranchState(branch: "feature/work", isDirty: false)
+ workspace.panelPullRequests[firstPanelId] = SidebarPullRequestState(
+ number: 1640,
+ label: "PR",
+ url: url,
+ status: .open,
+ branch: "feature/work",
+ checks: .pass
+ )
+ workspace.panelPullRequests[secondPanelId] = SidebarPullRequestState(
+ number: 1640,
+ label: "PR",
+ url: url,
+ status: .merged,
+ branch: "feature/work"
+ )
+
+ XCTAssertEqual(
+ workspace.sidebarPullRequestsInDisplayOrder(orderedPanelIds: [firstPanelId, secondPanelId]),
+ [
+ SidebarPullRequestState(
+ number: 1640,
+ label: "PR",
+ url: url,
+ status: .merged,
+ branch: "feature/work"
+ )
+ ]
+ )
+ }
+}
From 33d21ea19ebf8b841ab1c57bded9f61b83b316da Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Wed, 18 Mar 2026 01:16:43 -0700
Subject: [PATCH 05/24] feat(web): redirect cmux.dev to cmux.com with 301s for
SEO (#1716)
Redirect cmux.dev to cmux.com with 301s for SEO
---
.../[locale]/(legal)/privacy-policy/page.tsx | 2 +-
.../[locale]/(legal)/terms-of-service/page.tsx | 2 +-
web/app/[locale]/layout.tsx | 6 +++---
web/app/robots.ts | 2 +-
web/app/sitemap.ts | 2 +-
web/messages/ar.json | 2 +-
web/messages/bs.json | 2 +-
web/messages/da.json | 2 +-
web/messages/de.json | 2 +-
web/messages/en.json | 2 +-
web/messages/es.json | 2 +-
web/messages/fr.json | 2 +-
web/messages/it.json | 2 +-
web/messages/ja.json | 2 +-
web/messages/km.json | 2 +-
web/messages/ko.json | 2 +-
web/messages/no.json | 2 +-
web/messages/pl.json | 2 +-
web/messages/pt-BR.json | 2 +-
web/messages/ru.json | 2 +-
web/messages/th.json | 2 +-
web/messages/tr.json | 2 +-
web/messages/zh-CN.json | 2 +-
web/messages/zh-TW.json | 2 +-
web/proxy.ts | 17 ++++++++++++++++-
25 files changed, 42 insertions(+), 27 deletions(-)
diff --git a/web/app/[locale]/(legal)/privacy-policy/page.tsx b/web/app/[locale]/(legal)/privacy-policy/page.tsx
index fc945d36..98792851 100644
--- a/web/app/[locale]/(legal)/privacy-policy/page.tsx
+++ b/web/app/[locale]/(legal)/privacy-policy/page.tsx
@@ -21,7 +21,7 @@ export default function PrivacyPolicyPage() {
For purposes of this policy, “Site” refers to the
Company’s website at{" "}
- cmux.dev.
+ cmux.com.
“Application” refers to the cmux desktop application for
macOS. “Service” refers to the Site and Application
collectively. The terms “we,” “us,” and
diff --git a/web/app/[locale]/(legal)/terms-of-service/page.tsx b/web/app/[locale]/(legal)/terms-of-service/page.tsx
index 6a0a72ec..76b3f48b 100644
--- a/web/app/[locale]/(legal)/terms-of-service/page.tsx
+++ b/web/app/[locale]/(legal)/terms-of-service/page.tsx
@@ -13,7 +13,7 @@ export default function TermsOfServicePage() {
The website located at{" "}
- cmux.dev (the
+ cmux.com (the
“Site”) and the cmux desktop application (the
“Application”) are copyrighted works belonging to Manaflow
(“Company”, “us”, “our”, and
diff --git a/web/app/[locale]/layout.tsx b/web/app/[locale]/layout.tsx
index 4e1f6a79..cc230873 100644
--- a/web/app/[locale]/layout.tsx
+++ b/web/app/[locale]/layout.tsx
@@ -31,7 +31,7 @@ export async function generateMetadata({
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "meta" });
const url =
- locale === "en" ? "https://cmux.dev" : `https://cmux.dev/${locale}`;
+ locale === "en" ? "https://cmux.com" : `https://cmux.com/${locale}`;
return {
title: t("title"),
description: t("description"),
@@ -61,7 +61,7 @@ export async function generateMetadata({
title: t("title"),
description: t("ogDescription"),
},
- metadataBase: new URL("https://cmux.dev"),
+ metadataBase: new URL("https://cmux.com"),
};
}
@@ -94,7 +94,7 @@ export default async function LocaleLayout({
name: "cmux",
operatingSystem: "macOS",
applicationCategory: "DeveloperApplication",
- url: "https://cmux.dev",
+ url: "https://cmux.com",
downloadUrl:
"https://github.com/manaflow-ai/cmux/releases/latest/download/cmux-macos.dmg",
description:
diff --git a/web/app/robots.ts b/web/app/robots.ts
index 8cb44e16..1b471bcf 100644
--- a/web/app/robots.ts
+++ b/web/app/robots.ts
@@ -3,6 +3,6 @@ import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: "*", allow: "/" },
- sitemap: "https://cmux.dev/sitemap.xml",
+ sitemap: "https://cmux.com/sitemap.xml",
};
}
diff --git a/web/app/sitemap.ts b/web/app/sitemap.ts
index dfac9bb5..6705f4dc 100644
--- a/web/app/sitemap.ts
+++ b/web/app/sitemap.ts
@@ -2,7 +2,7 @@ import type { MetadataRoute } from "next";
import { locales } from "../i18n/routing";
export default function sitemap(): MetadataRoute.Sitemap {
- const base = "https://cmux.dev";
+ const base = "https://cmux.com";
const paths = [
{ path: "", lastModified: new Date(), changeFrequency: "weekly" as const, priority: 1 },
diff --git a/web/messages/ar.json b/web/messages/ar.json
index 099b3578..d8a65e1c 100644
--- a/web/messages/ar.json
+++ b/web/messages/ar.json
@@ -575,7 +575,7 @@
"afruth": "أعجبني، استخدمته في اليوم الماضي على ثلاثة مشاريع متوازية كل منها بعدة worktrees. استخدامه مع lazygit وyazi / nvim جعلني أكثر إنتاجية قليلاً من المعتاد دون الحاجة لملاحقة عدة نسخ من ghostty / iTerm. كما يبدو أكثر طبيعية من tmux.",
"northprint": "جربت cmux لأنه بدا جيداً، وهو جيد فعلاً",
"indykish": "cmux جيد جداً.",
- "kataring": "انتقلت إلى cmux.dev",
+ "kataring": "انتقلت إلى cmux.com",
"scottw": "كان هذا اكتشافاً مفيداً جداً. لا أستطيع التوصية به بما فيه الكفاية.",
"johnblythe": "حصلت عليه في عطلة نهاية الأسبوع وأحببته. كنت أنتظر شيئاً كهذا.",
"bchris91": "هذا بالضبط ما أردته. عمل مذهل، شكراً لكم!",
diff --git a/web/messages/bs.json b/web/messages/bs.json
index 6210c4b9..d68f4cec 100644
--- a/web/messages/bs.json
+++ b/web/messages/bs.json
@@ -575,7 +575,7 @@
"afruth": "Sviđa mi se, koristio sam ga proteklog dana na tri paralelna projekta, svaki sa nekoliko worktree-ova. Imati ovo u paru sa lazygit-om i yazi / nvim-om učinilo me malo produktivnijim nego obično bez jurnjave za više ghostty / iTerm instanci. Takođe se osjeća prirodnije od tmux-a.",
"northprint": "Probao sam cmux jer je izgledao dobro — dobar je",
"indykish": "cmux je prilično dobar.",
- "kataring": "Prešao sam na cmux.dev",
+ "kataring": "Prešao sam na cmux.com",
"scottw": "Ovo je bio tako koristan nalaz. Ne mogu ga dovoljno preporučiti.",
"johnblythe": "uzeo ovo tokom vikenda i volio sam ga. čekao sam nešto ovako.",
"bchris91": "Ovo je tačno ono što sam htio. Nevjerovatan posao, hvala!",
diff --git a/web/messages/da.json b/web/messages/da.json
index eeaf4a93..7481fefe 100644
--- a/web/messages/da.json
+++ b/web/messages/da.json
@@ -575,7 +575,7 @@
"afruth": "Jeg kan lide det, kørte det den seneste dag på tre parallelle projekter hver med flere worktrees. At have det parret med lazygit og yazi / nvim gjorde mig lidt mere produktiv end normalt uden at skulle jage flere ghostty / iTerm-instanser. Føles også mere naturligt end tmux.",
"northprint": "Prøvede cmux da det så godt ud — det er godt",
"indykish": "cmux er ret godt.",
- "kataring": "Skiftede til cmux.dev",
+ "kataring": "Skiftede til cmux.com",
"scottw": "Det har været sådan et nyttigt fund. Jeg kan ikke anbefale det nok.",
"johnblythe": "hentede det i weekenden og elskede det. har ventet på noget som dette.",
"bchris91": "Det er præcis hvad jeg har ønsket mig. Fantastisk arbejde, tak!",
diff --git a/web/messages/de.json b/web/messages/de.json
index df68123f..67f5f51b 100644
--- a/web/messages/de.json
+++ b/web/messages/de.json
@@ -575,7 +575,7 @@
"afruth": "Gefällt mir. Habe es gestern mit drei parallelen Projekten laufen lassen, jedes mit mehreren Worktrees. In Kombination mit lazygit und yazi/nvim war ich etwas produktiver als sonst, ohne mehreren ghostty/iTerm-Instanzen hinterherjagen zu müssen. Fühlt sich auch natürlicher an als tmux.",
"northprint": "cmux ausprobiert, weil es gut aussah, und es ist gut",
"indykish": "cmux ist ziemlich gut.",
- "kataring": "Zu cmux.dev gewechselt",
+ "kataring": "Zu cmux.com gewechselt",
"scottw": "Das war so ein nützlicher Fund. Ich kann es nur weiterempfehlen.",
"johnblythe": "Am Wochenende installiert und sofort begeistert. Habe auf sowas gewartet.",
"bchris91": "Genau das, was ich wollte. Tolle Arbeit, danke!",
diff --git a/web/messages/en.json b/web/messages/en.json
index 44437938..b7cd095f 100644
--- a/web/messages/en.json
+++ b/web/messages/en.json
@@ -577,7 +577,7 @@
"afruth": "I like it, ran it in the past day on three parallel projects each with several worktrees. Having this paired with lazygit and yazi / nvim made me a bit more productive than usual without having to chase multiple ghostty / iTerm instances. Also feels more natural than tmux.",
"northprint": "Tried cmux since it looked good — it's good",
"indykish": "cmux is pretty good.",
- "kataring": "Switched to cmux.dev",
+ "kataring": "Switched to cmux.com",
"scottw": "This has been such a useful find. I can't recommend it enough.",
"johnblythe": "grabbed this over the weekend and loved it. been waiting for something like this.",
"bchris91": "This is exactly what I've wanted. Amazing job thank you!",
diff --git a/web/messages/es.json b/web/messages/es.json
index 6913eaaa..0c131b4b 100644
--- a/web/messages/es.json
+++ b/web/messages/es.json
@@ -575,7 +575,7 @@
"afruth": "Me gusta. Ayer lo use con tres proyectos en paralelo, cada uno con varios worktrees. Combinado con lazygit y yazi/nvim me hizo un poco mas productivo de lo habitual sin tener que perseguir multiples instancias de ghostty/iTerm. Tambien se siente mas natural que tmux.",
"northprint": "Probe cmux porque se veia bien, y es bueno",
"indykish": "cmux esta bastante bien.",
- "kataring": "Me cambie a cmux.dev",
+ "kataring": "Me cambie a cmux.com",
"scottw": "Ha sido un hallazgo muy útil. No puedo recomendarlo suficiente.",
"johnblythe": "Lo instalé el fin de semana y me encantó. Estuve esperando algo así.",
"bchris91": "Es exactamente lo que quería. ¡Increíble trabajo, gracias!",
diff --git a/web/messages/fr.json b/web/messages/fr.json
index 61426dd9..51e275d3 100644
--- a/web/messages/fr.json
+++ b/web/messages/fr.json
@@ -575,7 +575,7 @@
"afruth": "J'aime bien. Je l'ai utilise hier sur trois projets en parallele, chacun avec plusieurs worktrees. En combinaison avec lazygit et yazi/nvim, j'etais un peu plus productif que d'habitude sans avoir a jongler entre plusieurs instances ghostty/iTerm. Ca semble aussi plus naturel que tmux.",
"northprint": "J'ai essaye cmux parce que ca avait l'air bien, et c'est bien",
"indykish": "cmux est plutot bon.",
- "kataring": "Je suis passe a cmux.dev",
+ "kataring": "Je suis passe a cmux.com",
"scottw": "C'est une decouverte tellement utile. Je ne peux que le recommander.",
"johnblythe": "Installe ce week-end et j'adore. J'attendais quelque chose comme ca.",
"bchris91": "C'est exactement ce que je voulais. Travail incroyable, merci !",
diff --git a/web/messages/it.json b/web/messages/it.json
index b002fd81..9b013557 100644
--- a/web/messages/it.json
+++ b/web/messages/it.json
@@ -575,7 +575,7 @@
"afruth": "Mi piace, l'ho usato nell'ultimo giorno su tre progetti paralleli, ciascuno con diversi worktree. Averlo insieme a lazygit e yazi / nvim mi ha reso un po' più produttivo del solito senza dover rincorrere più istanze di ghostty / iTerm. Inoltre sembra più naturale di tmux.",
"northprint": "Ho provato cmux perché sembrava buono, ed è buono",
"indykish": "cmux è piuttosto buono.",
- "kataring": "Sono passato a cmux.dev",
+ "kataring": "Sono passato a cmux.com",
"scottw": "È stata una scoperta così utile. Non lo consiglio mai abbastanza.",
"johnblythe": "l'ho preso nel weekend e mi è piaciuto. aspettavo qualcosa del genere.",
"bchris91": "Questo è esattamente quello che volevo. Lavoro fantastico, grazie!",
diff --git a/web/messages/ja.json b/web/messages/ja.json
index ea5d4351..d0371225 100644
--- a/web/messages/ja.json
+++ b/web/messages/ja.json
@@ -575,7 +575,7 @@
"afruth": "気に入った。昨日3つのプロジェクトをそれぞれ複数のworktreeで並行作業した。lazygitやyazi/nvimと組み合わせると、複数のghostty/iTermインスタンスを追いかけなくても普段より生産性が上がった。tmuxより自然な感じ。",
"northprint": "cmux良さそうなので入れてみたけれど、良い",
"indykish": "cmux、かなりいい。",
- "kataring": "cmux.dev に乗り換えた",
+ "kataring": "cmux.com に乗り換えた",
"scottw": "本当に便利な発見だった。みんなにおすすめしたい。",
"johnblythe": "週末に入れて気に入った。こういうのをずっと待ってた。",
"bchris91": "まさに欲しかったもの。最高です、ありがとう!",
diff --git a/web/messages/km.json b/web/messages/km.json
index 128e2142..050f848c 100644
--- a/web/messages/km.json
+++ b/web/messages/km.json
@@ -571,7 +571,7 @@
"afruth": "ខ្ញុំចូលចិត្តវា ប្រើក្នុងថ្ងៃកន្លងមកជាមួយគម្រោងបីស្របគ្នា នីមួយៗមាន worktree ជាច្រើន។ ការមានវាជាមួយ lazygit និង yazi / nvim ធ្វើឱ្យខ្ញុំផលិតភាពជាងធម្មតាដោយមិនចាំបាច់តាមរក ghostty / iTerm ជាច្រើនវិន្ដូ។ ក៏មានអារម្មណ៍ធម្មជាតិជាង tmux ដែរ។",
"northprint": "សាកល្បង cmux ព្រោះវាមើលទៅល្អ — វាល្អពិតមែន",
"indykish": "cmux ល្អដែរ។",
- "kataring": "ប្ដូរមកប្រើ cmux.dev",
+ "kataring": "ប្ដូរមកប្រើ cmux.com",
"scottw": "នេះជាការរកឃើញដ៏មានប្រយោជន៍។ ខ្ញុំណែនាំវាខ្លាំងណាស់។",
"johnblythe": "ទាញយកមកចុងសប្តាហ៍កន្លងមក ហើយចូលចិត្តវា។ រង់ចាំអ្វីបែបនេះយូរហើយ។",
"bchris91": "នេះជាអ្វីដែលខ្ញុំចង់បានពិតប្រាកដ។ ការងារអស្ចារ្យ អរគុណ!",
diff --git a/web/messages/ko.json b/web/messages/ko.json
index 156534fd..8d4560ee 100644
--- a/web/messages/ko.json
+++ b/web/messages/ko.json
@@ -575,7 +575,7 @@
"afruth": "마음에 들어요. 어제 세 개 프로젝트를 각각 여러 worktree로 병렬 작업했는데, lazygit이랑 yazi/nvim이랑 같이 쓰니까 여러 ghostty/iTerm 인스턴스를 쫓아다닐 필요 없이 평소보다 생산성이 올랐어요. tmux보다 자연스러운 느낌이에요.",
"northprint": "cmux 좋아 보여서 써봤는데, 좋다",
"indykish": "cmux 꽤 괜찮네요.",
- "kataring": "cmux.dev로 갈아탔다",
+ "kataring": "cmux.com로 갈아탔다",
"scottw": "정말 유용한 발견이에요. 충분히 추천할 수 없을 정도로.",
"johnblythe": "주말에 깔아봤는데 좋아요. 이런 걸 기다리고 있었어요.",
"bchris91": "딱 원하던 거예요. 정말 대단해요, 감사합니다!",
diff --git a/web/messages/no.json b/web/messages/no.json
index b289095e..310d22eb 100644
--- a/web/messages/no.json
+++ b/web/messages/no.json
@@ -575,7 +575,7 @@
"afruth": "Jeg liker det, brukte det i løpet av den siste dagen på tre parallelle prosjekter med flere worktrees. Å ha dette sammen med lazygit og yazi / nvim gjorde meg litt mer produktiv enn vanlig uten å måtte jakte på flere ghostty / iTerm-instanser. Føles også mer naturlig enn tmux.",
"northprint": "Prøvde cmux siden det så bra ut — det er bra",
"indykish": "cmux er ganske bra.",
- "kataring": "Byttet til cmux.dev",
+ "kataring": "Byttet til cmux.com",
"scottw": "Dette har vært et så nyttig funn. Kan ikke anbefale det nok.",
"johnblythe": "lastet dette ned i helgen og elsket det. har ventet på noe slikt.",
"bchris91": "Dette er nøyaktig det jeg har ønsket meg. Fantastisk jobb, takk!",
diff --git a/web/messages/pl.json b/web/messages/pl.json
index bb52ccbf..a46c137c 100644
--- a/web/messages/pl.json
+++ b/web/messages/pl.json
@@ -575,7 +575,7 @@
"afruth": "Podoba mi się, używałem tego ostatniego dnia na trzech równoległych projektach, każdy z kilkoma worktree'ami. Mając to w parze z lazygit i yazi / nvim byłem trochę bardziej produktywny niż zwykle, bez konieczności ścigania wielu instancji ghostty / iTerm. Czuję się też bardziej naturalnie niż tmux.",
"northprint": "Wypróbowałem cmux bo wyglądał dobrze, i jest dobry",
"indykish": "cmux jest całkiem niezły.",
- "kataring": "Przeszedłem na cmux.dev",
+ "kataring": "Przeszedłem na cmux.com",
"scottw": "To było takie przydatne odkrycie. Nie mogę go wystarczająco polecić.",
"johnblythe": "wziąłem to w weekend i pokochałem. czekałem na coś takiego.",
"bchris91": "To jest dokładnie to, czego chciałem. Świetna robota, dziękuję!",
diff --git a/web/messages/pt-BR.json b/web/messages/pt-BR.json
index 8b2b65f3..b5260f4b 100644
--- a/web/messages/pt-BR.json
+++ b/web/messages/pt-BR.json
@@ -575,7 +575,7 @@
"afruth": "Gostei, usei no último dia em três projetos paralelos, cada um com vários worktrees. Ter isso junto com lazygit e yazi / nvim me deixou um pouco mais produtivo que o normal sem precisar ficar correndo atrás de várias instâncias de ghostty / iTerm. Também parece mais natural que tmux.",
"northprint": "Experimentei o cmux porque parecia bom — é bom",
"indykish": "cmux é bem bom.",
- "kataring": "Migrei para o cmux.dev",
+ "kataring": "Migrei para o cmux.com",
"scottw": "Essa foi uma descoberta muito útil. Recomendo demais.",
"johnblythe": "baixei no fim de semana e adorei. estava esperando algo assim.",
"bchris91": "Isso é exatamente o que eu queria. Trabalho incrível, obrigado!",
diff --git a/web/messages/ru.json b/web/messages/ru.json
index ed2c38c2..01607654 100644
--- a/web/messages/ru.json
+++ b/web/messages/ru.json
@@ -575,7 +575,7 @@
"afruth": "Мне нравится, использовал целый день на трёх параллельных проектах, каждый с несколькими worktree. В связке с lazygit и yazi / nvim стал чуть продуктивнее обычного, без необходимости гоняться за несколькими экземплярами ghostty / iTerm. Также ощущается естественнее чем tmux.",
"northprint": "Попробовал cmux потому что выглядел хорошо, и он хорош",
"indykish": "cmux довольно хорош.",
- "kataring": "Перешёл на cmux.dev",
+ "kataring": "Перешёл на cmux.com",
"scottw": "Невероятно полезная находка. Рекомендую всем.",
"johnblythe": "скачал на выходных и полюбил. ждал чего-то подобного.",
"bchris91": "Это именно то, что я хотел. Потрясающая работа, спасибо!",
diff --git a/web/messages/th.json b/web/messages/th.json
index 7627ba68..c225b33c 100644
--- a/web/messages/th.json
+++ b/web/messages/th.json
@@ -571,7 +571,7 @@
"afruth": "ชอบเลย ใช้ในวันที่ผ่านมากับสามโปรเจกต์ที่ทำพร้อมกัน แต่ละโปรเจกต์มี worktree หลายอัน การมีสิ่งนี้คู่กับ lazygit และ yazi / nvim ทำให้ผมทำงานได้มากขึ้นกว่าปกติโดยไม่ต้องวิ่งไล่หลายหน้าต่าง ghostty / iTerm รู้สึกเป็นธรรมชาติกว่า tmux ด้วย",
"northprint": "ลอง cmux เพราะมันดูดี — มันดีจริง",
"indykish": "cmux ดีนะ",
- "kataring": "ย้ายมาใช้ cmux.dev แล้ว",
+ "kataring": "ย้ายมาใช้ cmux.com แล้ว",
"scottw": "นี่เป็นการค้นพบที่มีประโยชน์มาก แนะนำเลย",
"johnblythe": "โหลดมาเมื่อสุดสัปดาห์แล้วชอบมาก รอของแบบนี้มานานแล้ว",
"bchris91": "นี่คือสิ่งที่ผมต้องการเป๊ะเลย ทำได้ยอดเยี่ยม ขอบคุณ!",
diff --git a/web/messages/tr.json b/web/messages/tr.json
index cd6c4da0..c6eff9ff 100644
--- a/web/messages/tr.json
+++ b/web/messages/tr.json
@@ -575,7 +575,7 @@
"afruth": "Beğendim, son gün içinde her biri birden fazla worktree ile üç paralel projede kullandım. Bunu lazygit ve yazi / nvim ile birlikte kullanmak, birden fazla ghostty / iTerm örneğini kovalamak zorunda kalmadan normalden biraz daha üretken olmamı sağladı. tmux'tan daha doğal hissettiriyor.",
"northprint": "cmux'u iyi göründüğü için denedim — gerçekten iyi",
"indykish": "cmux oldukça iyi.",
- "kataring": "cmux.dev'e geçtim",
+ "kataring": "cmux.com'e geçtim",
"scottw": "Bu çok faydalı bir keşif oldu. Yeterince tavsiye edemem.",
"johnblythe": "hafta sonu indirdim ve bayıldım. böyle bir şey bekliyordum.",
"bchris91": "Bu tam olarak istediğim şey. Harika iş, teşekkürler!",
diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json
index e970d95d..e2250795 100644
--- a/web/messages/zh-CN.json
+++ b/web/messages/zh-CN.json
@@ -575,7 +575,7 @@
"afruth": "很喜欢,过去一天在三个并行项目上用了,每个都有多个 worktree。配合 lazygit 和 yazi/nvim,比以前高效多了,不用在多个 Ghostty/iTerm 实例之间切换。比 tmux 也更自然。",
"northprint": "试了 cmux,确实不错",
"indykish": "cmux 挺好用的。",
- "kataring": "换到 cmux.dev 了",
+ "kataring": "换到 cmux.com 了",
"scottw": "非常有用的发现。强烈推荐。",
"johnblythe": "周末用了,很喜欢。一直在等这样的工具。",
"bchris91": "这正是我想要的。做得太好了,谢谢!",
diff --git a/web/messages/zh-TW.json b/web/messages/zh-TW.json
index c6136785..89fce352 100644
--- a/web/messages/zh-TW.json
+++ b/web/messages/zh-TW.json
@@ -575,7 +575,7 @@
"afruth": "很喜歡,過去一天在三個並行專案上用了,每個都有多個 worktree。搭配 lazygit 和 yazi/nvim,比以前高效多了,不用在多個 Ghostty/iTerm 執行個體之間切換。比 tmux 也更自然。",
"northprint": "試了 cmux,確實不錯",
"indykish": "cmux 蠻好用的。",
- "kataring": "換到 cmux.dev 了",
+ "kataring": "換到 cmux.com 了",
"scottw": "非常有用的發現。強烈推薦。",
"johnblythe": "週末用了,很喜歡。一直在等這樣的工具。",
"bchris91": "這正是我想要的。做得太好了,謝謝!",
diff --git a/web/proxy.ts b/web/proxy.ts
index fcf488a6..547c423b 100644
--- a/web/proxy.ts
+++ b/web/proxy.ts
@@ -1,7 +1,22 @@
+import { type NextRequest, NextResponse } from "next/server";
import createMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
-export default createMiddleware(routing);
+const intlMiddleware = createMiddleware(routing);
+
+export default function middleware(request: NextRequest) {
+ const host = request.headers.get("host") ?? "";
+
+ // 301 redirect cmux.dev (and www.cmux.dev) to cmux.com, preserving path and query
+ if (host === "cmux.dev" || host === "www.cmux.dev") {
+ const url = new URL(request.url);
+ url.host = "cmux.com";
+ url.protocol = "https:";
+ return NextResponse.redirect(url.toString(), 301);
+ }
+
+ return intlMiddleware(request);
+}
export const config = {
matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],
From ac83af62ae508d2f7767fc56bf477f22fd0a7a28 Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Wed, 18 Mar 2026 01:17:25 -0700
Subject: [PATCH 06/24] Split 16k-line mega test file, bump CI timeout, stream
xcodebuild output (#1717)
CmuxWebViewKeyEquivalentTests.swift grew to 15,907 lines with 100+ test classes.
Swift compiles per-file, so this single file serialized all type-checking onto one
compiler process, pushing CI past the 20-minute timeout after core-file changes.
Split into 10 domain-based files (1k-3k lines each) so Xcode can compile them in
parallel. Also bump timeout-minutes from 20 to 30 for headroom, stream xcodebuild
output via tee instead of capturing to a variable (makes CI logs debuggable), and
add 5 test files that were missing from the pbxproj Sources build phase.
Co-authored-by: Lawrence Chen
---
.github/workflows/ci-macos-compat.yml | 15 +-
GhosttyTabs.xcodeproj/project.pbxproj | 51 +-
cmuxTests/BrowserConfigTests.swift | 3108 +++
cmuxTests/BrowserPanelTests.swift | 2935 +++
cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 15907 ----------------
cmuxTests/NotificationAndMenuBarTests.swift | 832 +
cmuxTests/OmnibarAndToolsTests.swift | 857 +
.../ShortcutAndCommandPaletteTests.swift | 965 +
cmuxTests/SidebarOrderingTests.swift | 941 +
cmuxTests/TabManagerUnitTests.swift | 976 +
cmuxTests/TerminalAndGhosttyTests.swift | 2578 +++
cmuxTests/WindowAndDragTests.swift | 1082 ++
cmuxTests/WorkspaceUnitTests.swift | 1899 ++
13 files changed, 16227 insertions(+), 15919 deletions(-)
create mode 100644 cmuxTests/BrowserConfigTests.swift
create mode 100644 cmuxTests/BrowserPanelTests.swift
delete mode 100644 cmuxTests/CmuxWebViewKeyEquivalentTests.swift
create mode 100644 cmuxTests/NotificationAndMenuBarTests.swift
create mode 100644 cmuxTests/OmnibarAndToolsTests.swift
create mode 100644 cmuxTests/ShortcutAndCommandPaletteTests.swift
create mode 100644 cmuxTests/SidebarOrderingTests.swift
create mode 100644 cmuxTests/TabManagerUnitTests.swift
create mode 100644 cmuxTests/TerminalAndGhosttyTests.swift
create mode 100644 cmuxTests/WindowAndDragTests.swift
create mode 100644 cmuxTests/WorkspaceUnitTests.swift
diff --git a/.github/workflows/ci-macos-compat.yml b/.github/workflows/ci-macos-compat.yml
index a0c72b11..12e827b1 100644
--- a/.github/workflows/ci-macos-compat.yml
+++ b/.github/workflows/ci-macos-compat.yml
@@ -15,11 +15,11 @@ jobs:
matrix:
include:
- os: warp-macos-15-arm64-6x
- timeout: 20
+ timeout: 30
smoke: true
skip_zig: false
- os: warp-macos-26-arm64-6x
- timeout: 20
+ timeout: 30
smoke: false
skip_zig: true # zig 0.15.2 MachO linker can't resolve libSystem on macOS 26
runs-on: ${{ matrix.os }}
@@ -133,8 +133,9 @@ jobs:
}
set +e
- OUTPUT=$(run_unit_tests)
- EXIT_CODE=$?
+ run_unit_tests | tee /tmp/test-output.txt
+ EXIT_CODE=${PIPESTATUS[0]}
+ OUTPUT=$(cat /tmp/test-output.txt)
set -e
# SwiftPM binary artifact resolution can occasionally fail on ephemeral
@@ -145,12 +146,12 @@ jobs:
mkdir -p ~/Library/Caches/org.swift.swiftpm
rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*
set +e
- OUTPUT=$(run_unit_tests)
- EXIT_CODE=$?
+ run_unit_tests | tee /tmp/test-output.txt
+ EXIT_CODE=${PIPESTATUS[0]}
+ OUTPUT=$(cat /tmp/test-output.txt)
set -e
fi
- echo "$OUTPUT"
if [ "$EXIT_CODE" -ne 0 ]; then
SUMMARY=$(echo "$OUTPUT" | grep "Executed.*tests.*with.*failures" | tail -1)
if echo "$SUMMARY" | grep -q "(0 unexpected)"; then
diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj
index 1571043e..0a4da569 100644
--- a/GhosttyTabs.xcodeproj/project.pbxproj
+++ b/GhosttyTabs.xcodeproj/project.pbxproj
@@ -86,7 +86,6 @@
D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */; };
FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */; };
E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */; };
- F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */; };
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 */; };
@@ -103,7 +102,22 @@
DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000001 /* Localizable.xcstrings */; };
DA7A10CA710E000000000004 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000002 /* InfoPlist.xcstrings */; };
A5001623 /* cmux.sdef in Resources */ = {isa = PBXBuildFile; fileRef = A5001622 /* cmux.sdef */; };
- /* End PBXBuildFile section */
+ E12E88F82733EC42F32C36A3 /* BrowserConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */; };
+ 1F14445B9627DE9D3AF4FD2E /* BrowserPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */; };
+ 46F6AC15863EC84DCD3770A2 /* TerminalAndGhosttyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */; };
+ 6B524A0BA34FD46A771335AB /* WorkspaceUnitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71F8ED91A4B55D34BE6A0668 /* WorkspaceUnitTests.swift */; };
+ 063BC42CEE257D6213A2E30C /* WindowAndDragTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */; };
+ 1521D55DC63D5E5FC4955E31 /* ShortcutAndCommandPaletteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6083A7DAD962E287FC2FFE94 /* ShortcutAndCommandPaletteTests.swift */; };
+ CB23911D7E131E8FBC9B82B6 /* SidebarOrderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */; };
+ 4378399A7C0245EF8186F306 /* OmnibarAndToolsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B09C007F42697761B5F1A2AB /* OmnibarAndToolsTests.swift */; };
+ 734F49D37E543DD01C2F4FEF /* NotificationAndMenuBarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */; };
+ B6BF3DC98DB1495E57900199 /* TabManagerUnitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */; };
+ DCC935C5F55C1DCB33E25521 /* WorkspacePullRequestSidebarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14A7DC53B9CA33BE2A421711 /* WorkspacePullRequestSidebarTests.swift */; };
+ 0F2C25F9170130F8DC09DD1B /* WorkspaceManualUnreadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D301919B10F22B8708E8883 /* WorkspaceManualUnreadTests.swift */; };
+ CA39C0304FE351A21C372429 /* SidebarWidthPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */; };
+ 8C4BBF2DEF6DF93F395A9EE7 /* TerminalControllerSocketSecurityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */; };
+ 2BB56A710BB1FC50367E5BCF /* TabManagerSessionSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */; };
+ /* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
A5001020 /* Embed Frameworks */ = {
@@ -236,7 +250,6 @@
D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarSuggestionsUITests.swift; sourceTree = ""; };
FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportProfilesUITests.swift; sourceTree = ""; };
E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuKeyEquivalentRoutingUITests.swift; sourceTree = ""; };
- F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewKeyEquivalentTests.swift; sourceTree = ""; };
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = ""; };
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = ""; };
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = ""; };
@@ -253,6 +266,21 @@
DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; };
DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; };
A5001622 /* cmux.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = cmux.sdef; sourceTree = ""; };
+ 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserConfigTests.swift; sourceTree = ""; };
+ 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPanelTests.swift; sourceTree = ""; };
+ 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAndGhosttyTests.swift; sourceTree = ""; };
+ 71F8ED91A4B55D34BE6A0668 /* WorkspaceUnitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceUnitTests.swift; sourceTree = ""; };
+ BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAndDragTests.swift; sourceTree = ""; };
+ 6083A7DAD962E287FC2FFE94 /* ShortcutAndCommandPaletteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutAndCommandPaletteTests.swift; sourceTree = ""; };
+ BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarOrderingTests.swift; sourceTree = ""; };
+ B09C007F42697761B5F1A2AB /* OmnibarAndToolsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmnibarAndToolsTests.swift; sourceTree = ""; };
+ D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationAndMenuBarTests.swift; sourceTree = ""; };
+ 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerUnitTests.swift; sourceTree = ""; };
+ 14A7DC53B9CA33BE2A421711 /* WorkspacePullRequestSidebarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePullRequestSidebarTests.swift; sourceTree = ""; };
+ 1D301919B10F22B8708E8883 /* WorkspaceManualUnreadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceManualUnreadTests.swift; sourceTree = ""; };
+ EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWidthPolicyTests.swift; sourceTree = ""; };
+ 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllerSocketSecurityTests.swift; sourceTree = ""; };
+ 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerSessionSnapshotTests.swift; sourceTree = ""; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -475,7 +503,6 @@
F1000003A1B2C3D4E5F60718 /* cmuxTests */ = {
isa = PBXGroup;
children = (
- F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */,
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */,
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */,
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */,
@@ -489,6 +516,21 @@
FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */,
A5008380 /* BrowserFindJavaScriptTests.swift */,
A5008382 /* CommandPaletteSearchEngineTests.swift */,
+ 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */,
+ 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */,
+ 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */,
+ 71F8ED91A4B55D34BE6A0668 /* WorkspaceUnitTests.swift */,
+ BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */,
+ 6083A7DAD962E287FC2FFE94 /* ShortcutAndCommandPaletteTests.swift */,
+ BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */,
+ B09C007F42697761B5F1A2AB /* OmnibarAndToolsTests.swift */,
+ D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */,
+ 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */,
+ 14A7DC53B9CA33BE2A421711 /* WorkspacePullRequestSidebarTests.swift */,
+ 1D301919B10F22B8708E8883 /* WorkspaceManualUnreadTests.swift */,
+ EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */,
+ 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */,
+ 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */,
);
path = cmuxTests;
sourceTree = "";
@@ -719,7 +761,6 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
- F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */,
F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */,
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */,
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */,
diff --git a/cmuxTests/BrowserConfigTests.swift b/cmuxTests/BrowserConfigTests.swift
new file mode 100644
index 00000000..487c680c
--- /dev/null
+++ b/cmuxTests/BrowserConfigTests.swift
@@ -0,0 +1,3108 @@
+import XCTest
+import AppKit
+import SwiftUI
+import UniformTypeIdentifiers
+import WebKit
+import ObjectiveC.runtime
+import Bonsplit
+import UserNotifications
+
+#if canImport(cmux_DEV)
+@testable import cmux_DEV
+#elseif canImport(cmux)
+@testable import cmux
+#endif
+
+var cmuxUnitTestInspectorAssociationKey: UInt8 = 0
+var cmuxUnitTestInspectorOverrideInstalled = false
+
+extension CmuxWebView {
+ @objc func cmuxUnitTestInspector() -> NSObject? {
+ objc_getAssociatedObject(self, &cmuxUnitTestInspectorAssociationKey) as? NSObject
+ }
+}
+
+extension WKWebView {
+ func cmuxSetUnitTestInspector(_ inspector: NSObject?) {
+ objc_setAssociatedObject(
+ self,
+ &cmuxUnitTestInspectorAssociationKey,
+ inspector,
+ .OBJC_ASSOCIATION_RETAIN_NONATOMIC
+ )
+ }
+}
+
+func installCmuxUnitTestInspectorOverride() {
+ guard !cmuxUnitTestInspectorOverrideInstalled else { return }
+
+ guard let replacementMethod = class_getInstanceMethod(
+ CmuxWebView.self,
+ #selector(CmuxWebView.cmuxUnitTestInspector)
+ ) else {
+ fatalError("Unable to locate test inspector replacement method")
+ }
+
+ let added = class_addMethod(
+ CmuxWebView.self,
+ NSSelectorFromString("_inspector"),
+ method_getImplementation(replacementMethod),
+ method_getTypeEncoding(replacementMethod)
+ )
+ guard added else {
+ fatalError("Unable to install CmuxWebView _inspector test override")
+ }
+
+ cmuxUnitTestInspectorOverrideInstalled = true
+}
+
+final class CmuxWebViewKeyEquivalentTests: XCTestCase {
+ private final class ActionSpy: NSObject {
+ private(set) var invoked: Bool = false
+
+ @objc func didInvoke(_ sender: Any?) {
+ invoked = true
+ }
+ }
+
+ private final class WindowCyclingActionSpy: NSObject {
+ weak var firstWindow: NSWindow?
+ weak var secondWindow: NSWindow?
+ private(set) var invocationCount = 0
+
+ @objc func cycleWindow(_ sender: Any?) {
+ invocationCount += 1
+ guard let firstWindow, let secondWindow else { return }
+
+ if NSApp.keyWindow === firstWindow {
+ secondWindow.makeKeyAndOrderFront(nil)
+ } else {
+ firstWindow.makeKeyAndOrderFront(nil)
+ }
+ }
+ }
+
+ private final class FirstResponderView: NSView {
+ override var acceptsFirstResponder: Bool { true }
+ }
+
+ private final class DelegateProbeTextView: NSTextView {
+ private(set) var delegateReadCount = 0
+
+ override var delegate: NSTextViewDelegate? {
+ get {
+ delegateReadCount += 1
+ return super.delegate
+ }
+ set {
+ super.delegate = newValue
+ }
+ }
+ }
+
+ private final class FieldEditorProbeTextView: NSTextView {
+ private(set) var delegateReadCount = 0
+
+ override var delegate: NSTextViewDelegate? {
+ get {
+ delegateReadCount += 1
+ return super.delegate
+ }
+ set {
+ super.delegate = newValue
+ }
+ }
+
+ override var isFieldEditor: Bool {
+ get { true }
+ set {}
+ }
+ }
+ func testCmdNRoutesToMainMenuWhenWebViewIsFirstResponder() {
+ let spy = ActionSpy()
+ installMenu(spy: spy, key: "n", modifiers: [.command])
+
+ let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
+ let event = makeKeyDownEvent(key: "n", modifiers: [.command], keyCode: 45) // kVK_ANSI_N
+ XCTAssertNotNil(event)
+
+ XCTAssertTrue(webView.performKeyEquivalent(with: event!))
+ XCTAssertTrue(spy.invoked)
+ }
+
+ func testCmdWRoutesToMainMenuWhenWebViewIsFirstResponder() {
+ let spy = ActionSpy()
+ installMenu(spy: spy, key: "w", modifiers: [.command])
+
+ let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
+ let event = makeKeyDownEvent(key: "w", modifiers: [.command], keyCode: 13) // kVK_ANSI_W
+ XCTAssertNotNil(event)
+
+ XCTAssertTrue(webView.performKeyEquivalent(with: event!))
+ XCTAssertTrue(spy.invoked)
+ }
+
+ func testCmdRRoutesToMainMenuWhenWebViewIsFirstResponder() {
+ let spy = ActionSpy()
+ installMenu(spy: spy, key: "r", modifiers: [.command])
+
+ let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
+ let event = makeKeyDownEvent(key: "r", modifiers: [.command], keyCode: 15) // kVK_ANSI_R
+ XCTAssertNotNil(event)
+
+ XCTAssertTrue(webView.performKeyEquivalent(with: event!))
+ XCTAssertTrue(spy.invoked)
+ }
+
+ func testReturnDoesNotRouteToMainMenuWhenWebViewIsFirstResponder() {
+ let spy = ActionSpy()
+ installMenu(spy: spy, key: "\r", modifiers: [])
+
+ let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
+ let event = makeKeyDownEvent(key: "\r", modifiers: [], keyCode: 36) // kVK_Return
+ XCTAssertNotNil(event)
+
+ XCTAssertFalse(webView.performKeyEquivalent(with: event!))
+ XCTAssertFalse(spy.invoked)
+ }
+
+ func testCmdReturnDoesNotRouteToMainMenuWhenWebViewIsFirstResponder() {
+ let spy = ActionSpy()
+ installMenu(spy: spy, key: "\r", modifiers: [.command])
+
+ let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
+ let event = makeKeyDownEvent(key: "\r", modifiers: [.command], keyCode: 36) // kVK_Return
+ XCTAssertNotNil(event)
+
+ XCTAssertFalse(webView.performKeyEquivalent(with: event!))
+ XCTAssertFalse(spy.invoked)
+ }
+
+ func testKeypadEnterDoesNotRouteToMainMenuWhenWebViewIsFirstResponder() {
+ let spy = ActionSpy()
+ installMenu(spy: spy, key: "\r", modifiers: [])
+
+ let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
+ let event = makeKeyDownEvent(key: "\r", modifiers: [], keyCode: 76) // kVK_ANSI_KeypadEnter
+ XCTAssertNotNil(event)
+
+ XCTAssertFalse(webView.performKeyEquivalent(with: event!))
+ XCTAssertFalse(spy.invoked)
+ }
+
+ @MainActor
+ func testCanBlockFirstResponderAcquisitionWhenPaneIsUnfocused() {
+ _ = NSApplication.shared
+
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
+ window.contentView = container
+
+ let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration())
+ webView.autoresizingMask = [.width, .height]
+ container.addSubview(webView)
+
+ window.makeKeyAndOrderFront(nil)
+ defer { window.orderOut(nil) }
+
+ webView.allowsFirstResponderAcquisition = true
+ XCTAssertTrue(window.makeFirstResponder(webView))
+
+ _ = window.makeFirstResponder(nil)
+ webView.allowsFirstResponderAcquisition = false
+ XCTAssertFalse(webView.becomeFirstResponder())
+
+ _ = window.makeFirstResponder(webView)
+ if let firstResponderView = window.firstResponder as? NSView {
+ XCTAssertFalse(firstResponderView === webView || firstResponderView.isDescendant(of: webView))
+ }
+ }
+
+ @MainActor
+ func testPointerFocusAllowanceCanTemporarilyOverrideBlockedFirstResponderAcquisition() {
+ _ = NSApplication.shared
+
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
+ window.contentView = container
+
+ let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration())
+ webView.autoresizingMask = [.width, .height]
+ container.addSubview(webView)
+
+ window.makeKeyAndOrderFront(nil)
+ defer { window.orderOut(nil) }
+
+ webView.allowsFirstResponderAcquisition = false
+ _ = window.makeFirstResponder(nil)
+ XCTAssertFalse(webView.becomeFirstResponder(), "Expected focus to stay blocked by policy")
+
+ webView.withPointerFocusAllowance {
+ XCTAssertTrue(webView.becomeFirstResponder(), "Expected explicit pointer intent to bypass policy")
+ }
+
+ _ = window.makeFirstResponder(nil)
+ XCTAssertFalse(webView.becomeFirstResponder(), "Expected pointer allowance to be temporary")
+ }
+
+ @MainActor
+ func testWindowFirstResponderGuardBlocksDescendantWhenPaneIsUnfocused() {
+ _ = NSApplication.shared
+ AppDelegate.installWindowResponderSwizzlesForTesting()
+
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
+ window.contentView = container
+
+ let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration())
+ webView.autoresizingMask = [.width, .height]
+ container.addSubview(webView)
+
+ let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10))
+ webView.addSubview(descendant)
+
+ window.makeKeyAndOrderFront(nil)
+ defer { window.orderOut(nil) }
+
+ webView.allowsFirstResponderAcquisition = true
+ XCTAssertTrue(window.makeFirstResponder(descendant))
+
+ _ = window.makeFirstResponder(nil)
+ webView.allowsFirstResponderAcquisition = false
+ XCTAssertFalse(window.makeFirstResponder(descendant))
+
+ if let firstResponderView = window.firstResponder as? NSView {
+ XCTAssertFalse(firstResponderView === descendant || firstResponderView.isDescendant(of: webView))
+ }
+ }
+
+ @MainActor
+ func testWindowFirstResponderGuardAllowsDescendantDuringPointerFocusAllowance() {
+ _ = NSApplication.shared
+ AppDelegate.installWindowResponderSwizzlesForTesting()
+
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
+ window.contentView = container
+
+ let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration())
+ webView.autoresizingMask = [.width, .height]
+ container.addSubview(webView)
+
+ let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10))
+ webView.addSubview(descendant)
+
+ window.makeKeyAndOrderFront(nil)
+ defer { window.orderOut(nil) }
+
+ webView.allowsFirstResponderAcquisition = false
+ _ = window.makeFirstResponder(nil)
+ XCTAssertFalse(window.makeFirstResponder(descendant), "Expected blocked focus outside pointer allowance")
+
+ _ = window.makeFirstResponder(nil)
+ webView.withPointerFocusAllowance {
+ XCTAssertTrue(window.makeFirstResponder(descendant), "Expected pointer allowance to bypass guard")
+ }
+
+ _ = window.makeFirstResponder(nil)
+ XCTAssertFalse(window.makeFirstResponder(descendant), "Expected pointer allowance to remain temporary")
+ }
+
+ @MainActor
+ func testWindowFirstResponderGuardAllowsPointerInitiatedClickFocusWhenPolicyIsBlocked() {
+ _ = NSApplication.shared
+ AppDelegate.installWindowResponderSwizzlesForTesting()
+
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
+ window.contentView = container
+
+ let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration())
+ webView.autoresizingMask = [.width, .height]
+ container.addSubview(webView)
+
+ let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10))
+ webView.addSubview(descendant)
+
+ window.makeKeyAndOrderFront(nil)
+ defer {
+ AppDelegate.clearWindowFirstResponderGuardTesting()
+ window.orderOut(nil)
+ }
+
+ webView.allowsFirstResponderAcquisition = false
+ _ = window.makeFirstResponder(nil)
+ XCTAssertFalse(window.makeFirstResponder(descendant), "Expected blocked focus without pointer click context")
+
+ let timestamp = ProcessInfo.processInfo.systemUptime
+ let pointerDownEvent = NSEvent.mouseEvent(
+ with: .leftMouseDown,
+ location: NSPoint(x: 5, y: 5),
+ modifierFlags: [],
+ timestamp: timestamp,
+ windowNumber: window.windowNumber,
+ context: nil,
+ eventNumber: 1,
+ clickCount: 1,
+ pressure: 1.0
+ )
+ XCTAssertNotNil(pointerDownEvent)
+
+ AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: pointerDownEvent, hitView: descendant)
+ _ = window.makeFirstResponder(nil)
+ XCTAssertTrue(window.makeFirstResponder(descendant), "Expected pointer click context to bypass blocked policy")
+
+ AppDelegate.clearWindowFirstResponderGuardTesting()
+ _ = window.makeFirstResponder(nil)
+ XCTAssertFalse(window.makeFirstResponder(descendant), "Expected pointer bypass to be limited to click context")
+ }
+
+ @MainActor
+ func testWindowFirstResponderGuardAllowsPointerInitiatedClickFocusFromPortalHostedInspectorSibling() {
+ _ = NSApplication.shared
+ AppDelegate.installWindowResponderSwizzlesForTesting()
+
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ let contentView = NSView(frame: window.contentRect(forFrameRect: window.frame))
+ window.contentView = contentView
+
+ window.makeKeyAndOrderFront(nil)
+ defer {
+ AppDelegate.clearWindowFirstResponderGuardTesting()
+ window.orderOut(nil)
+ }
+
+ guard let container = contentView.superview else {
+ XCTFail("Expected content container")
+ return
+ }
+
+ let hostFrame = container.convert(contentView.bounds, from: contentView)
+ let host = WindowBrowserHostView(frame: hostFrame)
+ host.autoresizingMask = [.width, .height]
+ container.addSubview(host, positioned: .above, relativeTo: contentView)
+
+ let slot = WindowBrowserSlotView(frame: host.bounds)
+ slot.autoresizingMask = [.width, .height]
+ host.addSubview(slot)
+
+ let webView = CmuxWebView(frame: slot.bounds, configuration: WKWebViewConfiguration())
+ webView.autoresizingMask = [.width, .height]
+ slot.addSubview(webView)
+
+ let inspector = FirstResponderView(frame: NSRect(x: 440, y: 0, width: 200, height: slot.bounds.height))
+ inspector.autoresizingMask = [.minXMargin, .height]
+ slot.addSubview(inspector)
+
+ webView.allowsFirstResponderAcquisition = false
+ _ = window.makeFirstResponder(nil)
+ XCTAssertFalse(
+ window.makeFirstResponder(inspector),
+ "Expected portal-hosted inspector focus to stay blocked without pointer click context"
+ )
+
+ let pointInInspector = NSPoint(x: inspector.bounds.midX, y: inspector.bounds.midY)
+ let pointInWindow = inspector.convert(pointInInspector, to: nil)
+ let pointerDownEvent = NSEvent.mouseEvent(
+ with: .leftMouseDown,
+ location: pointInWindow,
+ modifierFlags: [],
+ timestamp: ProcessInfo.processInfo.systemUptime,
+ windowNumber: window.windowNumber,
+ context: nil,
+ eventNumber: 1,
+ clickCount: 1,
+ pressure: 1.0
+ )
+ XCTAssertNotNil(pointerDownEvent)
+
+ AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: pointerDownEvent, hitView: nil)
+ _ = window.makeFirstResponder(nil)
+ XCTAssertTrue(
+ window.makeFirstResponder(inspector),
+ "Expected portal-hosted inspector click to bypass blocked policy using the overlay hit target"
+ )
+ }
+
+ @MainActor
+ func testWindowFirstResponderGuardAllowsPointerInitiatedClickFocusFromBoundPortalInspectorSiblingWhenHitTestMisses() {
+ _ = NSApplication.shared
+ AppDelegate.installWindowResponderSwizzlesForTesting()
+
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ let contentView = NSView(frame: window.contentRect(forFrameRect: window.frame))
+ window.contentView = contentView
+
+ let anchor = NSView(frame: NSRect(x: 80, y: 60, width: 480, height: 260))
+ contentView.addSubview(anchor)
+
+ let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
+
+ window.makeKeyAndOrderFront(nil)
+ contentView.layoutSubtreeIfNeeded()
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+ BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true, zPriority: 1)
+ BrowserWindowPortalRegistry.synchronizeForAnchor(anchor)
+
+ defer {
+ BrowserWindowPortalRegistry.detach(webView: webView)
+ AppDelegate.clearWindowFirstResponderGuardTesting()
+ window.orderOut(nil)
+ }
+
+ guard let slot = webView.superview as? WindowBrowserSlotView else {
+ XCTFail("Expected bound portal slot")
+ return
+ }
+
+ let inspector = FirstResponderView(frame: NSRect(x: 320, y: 0, width: 160, height: slot.bounds.height))
+ inspector.autoresizingMask = [.minXMargin, .height]
+ slot.addSubview(inspector)
+
+ webView.allowsFirstResponderAcquisition = false
+ _ = window.makeFirstResponder(nil)
+ XCTAssertFalse(
+ window.makeFirstResponder(inspector),
+ "Expected bound portal inspector focus to stay blocked without pointer click context"
+ )
+
+ let pointInInspector = NSPoint(x: inspector.bounds.midX, y: inspector.bounds.midY)
+ let pointInWindow = inspector.convert(pointInInspector, to: nil)
+ XCTAssertTrue(
+ BrowserWindowPortalRegistry.webViewAtWindowPoint(pointInWindow, in: window) === webView,
+ "Expected portal registry to resolve the owning web view from a click inside inspector chrome"
+ )
+
+ let pointerDownEvent = NSEvent.mouseEvent(
+ with: .leftMouseDown,
+ location: pointInWindow,
+ modifierFlags: [],
+ timestamp: ProcessInfo.processInfo.systemUptime,
+ windowNumber: window.windowNumber,
+ context: nil,
+ eventNumber: 1,
+ clickCount: 1,
+ pressure: 1.0
+ )
+ XCTAssertNotNil(pointerDownEvent)
+
+ AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: pointerDownEvent, hitView: nil)
+ _ = window.makeFirstResponder(nil)
+ XCTAssertTrue(
+ window.makeFirstResponder(inspector),
+ "Expected bound portal inspector click to bypass blocked policy through portal registry fallback"
+ )
+ }
+
+ @MainActor
+ func testWindowFirstResponderGuardAvoidsTextViewDelegateLookupForWebViewResolution() {
+ _ = NSApplication.shared
+ AppDelegate.installWindowResponderSwizzlesForTesting()
+
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
+ window.contentView = container
+
+ let textView = DelegateProbeTextView(frame: NSRect(x: 0, y: 0, width: 100, height: 40))
+ container.addSubview(textView)
+
+ window.makeKeyAndOrderFront(nil)
+ defer { window.orderOut(nil) }
+
+ _ = window.makeFirstResponder(nil)
+ _ = window.makeFirstResponder(textView)
+
+ XCTAssertEqual(
+ textView.delegateReadCount,
+ 0,
+ "WebView ownership resolution should not touch NSTextView.delegate (unsafe-unretained in AppKit)"
+ )
+ }
+
+ @MainActor
+ func testWindowFirstResponderGuardResolvesTrackedWebViewForFieldEditorResponder() {
+ _ = NSApplication.shared
+ AppDelegate.installWindowResponderSwizzlesForTesting()
+
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
+ window.contentView = container
+
+ let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration())
+ webView.autoresizingMask = [.width, .height]
+ container.addSubview(webView)
+
+ let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10))
+ webView.addSubview(descendant)
+
+ let fieldEditor = FieldEditorProbeTextView(frame: NSRect(x: 0, y: 0, width: 100, height: 20))
+
+ window.makeKeyAndOrderFront(nil)
+ defer {
+ AppDelegate.clearWindowFirstResponderGuardTesting()
+ window.orderOut(nil)
+ }
+
+ webView.allowsFirstResponderAcquisition = true
+ XCTAssertTrue(window.makeFirstResponder(descendant))
+
+ let timestamp = ProcessInfo.processInfo.systemUptime
+ let pointerDownEvent = NSEvent.mouseEvent(
+ with: .leftMouseDown,
+ location: NSPoint(x: 5, y: 5),
+ modifierFlags: [],
+ timestamp: timestamp,
+ windowNumber: window.windowNumber,
+ context: nil,
+ eventNumber: 1,
+ clickCount: 1,
+ pressure: 1.0
+ )
+ XCTAssertNotNil(pointerDownEvent)
+
+ AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: pointerDownEvent, hitView: descendant)
+ XCTAssertTrue(window.makeFirstResponder(fieldEditor))
+
+ AppDelegate.clearWindowFirstResponderGuardTesting()
+ _ = window.makeFirstResponder(nil)
+ webView.allowsFirstResponderAcquisition = false
+ XCTAssertFalse(window.makeFirstResponder(fieldEditor))
+ XCTAssertEqual(
+ fieldEditor.delegateReadCount,
+ 0,
+ "Field-editor webview ownership should come from tracked associations, not NSTextView.delegate"
+ )
+ }
+
+ @MainActor
+ func testWindowFirstResponderBypassBlocksSwizzledMakeFirstResponder() {
+ _ = NSApplication.shared
+ AppDelegate.installWindowResponderSwizzlesForTesting()
+
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
+ window.contentView = container
+
+ let responder = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 80, height: 40))
+ container.addSubview(responder)
+
+ window.makeKeyAndOrderFront(nil)
+ defer { window.orderOut(nil) }
+
+ _ = window.makeFirstResponder(nil)
+ cmuxWithWindowFirstResponderBypass {
+ XCTAssertFalse(
+ window.makeFirstResponder(responder),
+ "Bypass scope should block transient first-responder changes during devtools auto-restore"
+ )
+ }
+ XCTAssertTrue(window.makeFirstResponder(responder))
+ }
+
+ @MainActor
+ func testCmdBacktickMenuActionThatChangesKeyWindowOnlyRunsOnceWhenTerminalIsFirstResponder() {
+ _ = NSApplication.shared
+ AppDelegate.installWindowResponderSwizzlesForTesting()
+
+ let firstWindow = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ let secondWindow = NSWindow(
+ contentRect: NSRect(x: 40, y: 40, width: 640, height: 420),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+
+ let firstContainer = NSView(frame: firstWindow.contentRect(forFrameRect: firstWindow.frame))
+ let secondContainer = NSView(frame: secondWindow.contentRect(forFrameRect: secondWindow.frame))
+ firstWindow.contentView = firstContainer
+ secondWindow.contentView = secondContainer
+
+ let firstTerminal = GhosttyNSView(frame: firstContainer.bounds)
+ firstTerminal.autoresizingMask = [.width, .height]
+ firstContainer.addSubview(firstTerminal)
+
+ let secondTerminal = GhosttyNSView(frame: secondContainer.bounds)
+ secondTerminal.autoresizingMask = [.width, .height]
+ secondContainer.addSubview(secondTerminal)
+
+ let spy = WindowCyclingActionSpy()
+ spy.firstWindow = firstWindow
+ spy.secondWindow = secondWindow
+ installMenu(
+ target: spy,
+ action: #selector(WindowCyclingActionSpy.cycleWindow(_:)),
+ key: "`",
+ modifiers: [.command]
+ )
+
+ secondWindow.orderFront(nil)
+ firstWindow.makeKeyAndOrderFront(nil)
+ defer {
+ secondWindow.orderOut(nil)
+ firstWindow.orderOut(nil)
+ }
+
+ XCTAssertTrue(firstWindow.makeFirstResponder(firstTerminal))
+ guard let event = makeKeyDownEvent(
+ key: "`",
+ modifiers: [.command],
+ keyCode: 50,
+ windowNumber: firstWindow.windowNumber
+ ) else {
+ XCTFail("Failed to construct Cmd+` event")
+ return
+ }
+
+ NSApp.sendEvent(event)
+ RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
+
+ XCTAssertEqual(spy.invocationCount, 1, "Cmd+` should only trigger one window-cycle action")
+ }
+
+ @MainActor
+ func testCmdBacktickDoesNotRouteDirectlyToMainMenuWhenWebViewIsFirstResponder() {
+ _ = NSApplication.shared
+
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+
+ let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
+ window.contentView = container
+
+ let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration())
+ webView.autoresizingMask = [.width, .height]
+ container.addSubview(webView)
+
+ let spy = ActionSpy()
+ installMenu(
+ target: spy,
+ action: #selector(ActionSpy.didInvoke(_:)),
+ key: "`",
+ modifiers: [.command]
+ )
+
+ window.makeKeyAndOrderFront(nil)
+ defer {
+ window.orderOut(nil)
+ }
+
+ XCTAssertTrue(window.makeFirstResponder(webView))
+ guard let event = makeKeyDownEvent(
+ key: "`",
+ modifiers: [.command],
+ keyCode: 50,
+ windowNumber: window.windowNumber
+ ) else {
+ XCTFail("Failed to construct Cmd+` event")
+ return
+ }
+
+ XCTAssertFalse(shouldRouteCommandEquivalentDirectlyToMainMenu(event))
+ _ = webView.performKeyEquivalent(with: event)
+ XCTAssertFalse(
+ spy.invoked,
+ "CmuxWebView should not route Cmd+` directly to the menu when WebKit is first responder"
+ )
+ }
+
+ private func installMenu(spy: ActionSpy, key: String, modifiers: NSEvent.ModifierFlags) {
+ installMenu(
+ target: spy,
+ action: #selector(ActionSpy.didInvoke(_:)),
+ key: key,
+ modifiers: modifiers
+ )
+ }
+
+ private func installMenu(
+ target: NSObject,
+ action: Selector,
+ key: String,
+ modifiers: NSEvent.ModifierFlags
+ ) {
+ let mainMenu = NSMenu()
+
+ let fileItem = NSMenuItem(title: "File", action: nil, keyEquivalent: "")
+ let fileMenu = NSMenu(title: "File")
+
+ let item = NSMenuItem(title: "Test Item", action: action, keyEquivalent: key)
+ item.keyEquivalentModifierMask = modifiers
+ item.target = target
+ fileMenu.addItem(item)
+
+ mainMenu.addItem(fileItem)
+ mainMenu.setSubmenu(fileMenu, for: fileItem)
+
+ // Ensure NSApp exists and has a menu for performKeyEquivalent to consult.
+ _ = NSApplication.shared
+ NSApp.mainMenu = mainMenu
+ }
+
+ private func makeKeyDownEvent(
+ key: String,
+ modifiers: NSEvent.ModifierFlags,
+ keyCode: UInt16,
+ windowNumber: Int = 0
+ ) -> 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
+ )
+ }
+}
+
+
+@MainActor
+final class CmuxWebViewContextMenuTests: XCTestCase {
+ private func makeRightMouseDownEvent() -> NSEvent {
+ guard let event = NSEvent.mouseEvent(
+ with: .rightMouseDown,
+ location: .zero,
+ modifierFlags: [],
+ timestamp: ProcessInfo.processInfo.systemUptime,
+ windowNumber: 0,
+ context: nil,
+ eventNumber: 0,
+ clickCount: 1,
+ pressure: 1.0
+ ) else {
+ fatalError("Failed to create rightMouseDown event")
+ }
+ return event
+ }
+
+ func testWillOpenMenuAddsOpenLinkInDefaultBrowserAndRoutesSelectionToDefaultBrowserOpener() {
+ _ = NSApplication.shared
+ let webView = CmuxWebView(frame: NSRect(x: 0, y: 0, width: 800, height: 600), configuration: WKWebViewConfiguration())
+ let menu = NSMenu()
+ let openLinkItem = NSMenuItem(title: "Open Link", action: nil, keyEquivalent: "")
+ openLinkItem.identifier = NSUserInterfaceItemIdentifier("WKMenuItemIdentifierOpenLink")
+ menu.addItem(openLinkItem)
+ menu.addItem(NSMenuItem(title: "Copy Link", action: nil, keyEquivalent: ""))
+
+ var openedURL: URL?
+ webView.contextMenuLinkURLProvider = { _, _, completion in
+ completion(URL(string: "https://example.com/docs")!)
+ }
+ webView.contextMenuDefaultBrowserOpener = { url in
+ openedURL = url
+ return true
+ }
+
+ webView.willOpenMenu(menu, with: makeRightMouseDownEvent())
+
+ guard let defaultBrowserItemIndex = menu.items.firstIndex(where: { $0.title == "Open Link in Default Browser" }) else {
+ XCTFail("Expected Open Link in Default Browser item in context menu")
+ return
+ }
+ guard let openLinkIndex = menu.items.firstIndex(where: { $0.identifier?.rawValue == "WKMenuItemIdentifierOpenLink" }) else {
+ XCTFail("Expected Open Link item in context menu")
+ return
+ }
+
+ XCTAssertEqual(defaultBrowserItemIndex, openLinkIndex + 1)
+ let defaultBrowserItem = menu.items[defaultBrowserItemIndex]
+ XCTAssertTrue(defaultBrowserItem.target === webView)
+ XCTAssertNotNil(defaultBrowserItem.action)
+
+ let dispatched = NSApp.sendAction(
+ defaultBrowserItem.action!,
+ to: defaultBrowserItem.target,
+ from: defaultBrowserItem
+ )
+ XCTAssertTrue(dispatched)
+ XCTAssertEqual(openedURL?.absoluteString, "https://example.com/docs")
+ }
+
+ func testWillOpenMenuSkipsDefaultBrowserItemWhenContextHasNoOpenLinkEntry() {
+ let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
+ let menu = NSMenu()
+ menu.addItem(NSMenuItem(title: "Back", action: nil, keyEquivalent: ""))
+ menu.addItem(NSMenuItem(title: "Forward", action: nil, keyEquivalent: ""))
+
+ webView.willOpenMenu(menu, with: makeRightMouseDownEvent())
+
+ XCTAssertFalse(menu.items.contains { $0.title == "Open Link in Default Browser" })
+ }
+
+ func testWillOpenMenuHooksDownloadImageToDiskMenuVariant() {
+ let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
+ let menu = NSMenu()
+ let originalTarget = NSObject()
+ let originalAction = NSSelectorFromString("downloadImageToDisk:")
+ let downloadItem = NSMenuItem(title: "Download Image As...", action: originalAction, keyEquivalent: "")
+ downloadItem.identifier = NSUserInterfaceItemIdentifier("WKMenuItemIdentifierDownloadImageToDisk")
+ downloadItem.target = originalTarget
+ menu.addItem(downloadItem)
+
+ webView.willOpenMenu(menu, with: makeRightMouseDownEvent())
+
+ XCTAssertTrue(downloadItem.target === webView)
+ XCTAssertNotNil(downloadItem.action)
+ XCTAssertNotEqual(downloadItem.action, originalAction)
+ }
+
+ func testWillOpenMenuHooksDownloadLinkedFileToDiskMenuVariant() {
+ let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
+ let menu = NSMenu()
+ let originalTarget = NSObject()
+ let originalAction = NSSelectorFromString("downloadLinkToDisk:")
+ let downloadItem = NSMenuItem(title: "Download Linked File As...", action: originalAction, keyEquivalent: "")
+ downloadItem.identifier = NSUserInterfaceItemIdentifier("WKMenuItemIdentifierDownloadLinkToDisk")
+ downloadItem.target = originalTarget
+ menu.addItem(downloadItem)
+
+ webView.willOpenMenu(menu, with: makeRightMouseDownEvent())
+
+ XCTAssertTrue(downloadItem.target === webView)
+ XCTAssertNotNil(downloadItem.action)
+ XCTAssertNotEqual(downloadItem.action, originalAction)
+ }
+}
+
+
+final class BrowserDevToolsButtonDebugSettingsTests: XCTestCase {
+ private func makeIsolatedDefaults() -> UserDefaults {
+ let suiteName = "BrowserDevToolsButtonDebugSettingsTests.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ fatalError("Failed to create defaults suite")
+ }
+ defaults.removePersistentDomain(forName: suiteName)
+ addTeardownBlock {
+ defaults.removePersistentDomain(forName: suiteName)
+ }
+ return defaults
+ }
+
+ func testIconCatalogIncludesExpandedChoices() {
+ XCTAssertGreaterThanOrEqual(BrowserDevToolsIconOption.allCases.count, 10)
+ XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.terminal))
+ XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.globe))
+ XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.curlyBracesSquare))
+ }
+
+ func testIconOptionFallsBackToDefaultForUnknownRawValue() {
+ let defaults = makeIsolatedDefaults()
+ defaults.set("this.symbol.does.not.exist", forKey: BrowserDevToolsButtonDebugSettings.iconNameKey)
+
+ XCTAssertEqual(
+ BrowserDevToolsButtonDebugSettings.iconOption(defaults: defaults),
+ BrowserDevToolsButtonDebugSettings.defaultIcon
+ )
+ }
+
+ func testColorOptionFallsBackToDefaultForUnknownRawValue() {
+ let defaults = makeIsolatedDefaults()
+ defaults.set("notAValidColor", forKey: BrowserDevToolsButtonDebugSettings.iconColorKey)
+
+ XCTAssertEqual(
+ BrowserDevToolsButtonDebugSettings.colorOption(defaults: defaults),
+ BrowserDevToolsButtonDebugSettings.defaultColor
+ )
+ }
+
+ func testBrowserToolbarAccessorySpacingDefaultsToTwoWhenUnset() {
+ let defaults = makeIsolatedDefaults()
+ defaults.removeObject(forKey: BrowserToolbarAccessorySpacingDebugSettings.key)
+
+ XCTAssertEqual(
+ BrowserToolbarAccessorySpacingDebugSettings.current(defaults: defaults),
+ BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing
+ )
+ }
+
+ func testBrowserToolbarAccessorySpacingFallsBackToDefaultForUnsupportedValue() {
+ let defaults = makeIsolatedDefaults()
+ defaults.set(99, forKey: BrowserToolbarAccessorySpacingDebugSettings.key)
+
+ XCTAssertEqual(
+ BrowserToolbarAccessorySpacingDebugSettings.current(defaults: defaults),
+ BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing
+ )
+ }
+
+ func testBrowserProfilePopoverPaddingDefaultsWhenUnset() {
+ let defaults = makeIsolatedDefaults()
+ defaults.removeObject(forKey: BrowserProfilePopoverDebugSettings.horizontalPaddingKey)
+ defaults.removeObject(forKey: BrowserProfilePopoverDebugSettings.verticalPaddingKey)
+
+ XCTAssertEqual(
+ BrowserProfilePopoverDebugSettings.currentHorizontalPadding(defaults: defaults),
+ BrowserProfilePopoverDebugSettings.defaultHorizontalPadding
+ )
+ XCTAssertEqual(
+ BrowserProfilePopoverDebugSettings.currentVerticalPadding(defaults: defaults),
+ BrowserProfilePopoverDebugSettings.defaultVerticalPadding
+ )
+ }
+
+ func testBrowserProfilePopoverPaddingFallsBackForUnsupportedValues() {
+ let defaults = makeIsolatedDefaults()
+ defaults.set(-3, forKey: BrowserProfilePopoverDebugSettings.horizontalPaddingKey)
+ defaults.set(999, forKey: BrowserProfilePopoverDebugSettings.verticalPaddingKey)
+
+ XCTAssertEqual(
+ BrowserProfilePopoverDebugSettings.currentHorizontalPadding(defaults: defaults),
+ BrowserProfilePopoverDebugSettings.defaultHorizontalPadding
+ )
+ XCTAssertEqual(
+ BrowserProfilePopoverDebugSettings.currentVerticalPadding(defaults: defaults),
+ BrowserProfilePopoverDebugSettings.defaultVerticalPadding
+ )
+ }
+
+ func testCopyPayloadUsesPersistedValues() {
+ let defaults = makeIsolatedDefaults()
+ defaults.set(BrowserDevToolsIconOption.scope.rawValue, forKey: BrowserDevToolsButtonDebugSettings.iconNameKey)
+ defaults.set(BrowserDevToolsIconColorOption.bonsplitActive.rawValue, forKey: BrowserDevToolsButtonDebugSettings.iconColorKey)
+
+ let payload = BrowserDevToolsButtonDebugSettings.copyPayload(defaults: defaults)
+ XCTAssertTrue(payload.contains("browserDevToolsIconName=scope"))
+ XCTAssertTrue(payload.contains("browserDevToolsIconColor=bonsplitActive"))
+ }
+}
+
+
+final class BrowserThemeSettingsTests: XCTestCase {
+ private func makeIsolatedDefaults() -> UserDefaults {
+ let suiteName = "BrowserThemeSettingsTests.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ fatalError("Failed to create defaults suite")
+ }
+ defaults.removePersistentDomain(forName: suiteName)
+ addTeardownBlock {
+ defaults.removePersistentDomain(forName: suiteName)
+ }
+ return defaults
+ }
+
+ func testDefaultsMatchConfiguredFallbacks() {
+ let defaults = makeIsolatedDefaults()
+ XCTAssertEqual(
+ BrowserThemeSettings.mode(defaults: defaults),
+ BrowserThemeSettings.defaultMode
+ )
+ }
+
+ func testModeReadsPersistedValue() {
+ let defaults = makeIsolatedDefaults()
+ defaults.set(BrowserThemeMode.dark.rawValue, forKey: BrowserThemeSettings.modeKey)
+ XCTAssertEqual(BrowserThemeSettings.mode(defaults: defaults), .dark)
+
+ defaults.set(BrowserThemeMode.light.rawValue, forKey: BrowserThemeSettings.modeKey)
+ XCTAssertEqual(BrowserThemeSettings.mode(defaults: defaults), .light)
+ }
+
+ func testModeMigratesLegacyForcedDarkModeFlag() {
+ let defaults = makeIsolatedDefaults()
+ defaults.set(true, forKey: BrowserThemeSettings.legacyForcedDarkModeEnabledKey)
+ XCTAssertEqual(BrowserThemeSettings.mode(defaults: defaults), .dark)
+ XCTAssertEqual(defaults.string(forKey: BrowserThemeSettings.modeKey), BrowserThemeMode.dark.rawValue)
+
+ let otherDefaults = makeIsolatedDefaults()
+ otherDefaults.set(false, forKey: BrowserThemeSettings.legacyForcedDarkModeEnabledKey)
+ XCTAssertEqual(BrowserThemeSettings.mode(defaults: otherDefaults), .system)
+ XCTAssertEqual(otherDefaults.string(forKey: BrowserThemeSettings.modeKey), BrowserThemeMode.system.rawValue)
+ }
+}
+
+
+final class BrowserDeveloperToolsShortcutDefaultsTests: XCTestCase {
+ func testSafariDefaultShortcutForToggleDeveloperTools() {
+ let shortcut = KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut
+ XCTAssertEqual(shortcut.key, "i")
+ XCTAssertTrue(shortcut.command)
+ XCTAssertTrue(shortcut.option)
+ XCTAssertFalse(shortcut.shift)
+ XCTAssertFalse(shortcut.control)
+ }
+
+ func testSafariDefaultShortcutForShowJavaScriptConsole() {
+ let shortcut = KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultShortcut
+ XCTAssertEqual(shortcut.key, "c")
+ XCTAssertTrue(shortcut.command)
+ XCTAssertTrue(shortcut.option)
+ XCTAssertFalse(shortcut.shift)
+ XCTAssertFalse(shortcut.control)
+ }
+}
+
+
+@MainActor
+final class BrowserDeveloperToolsConfigurationTests: XCTestCase {
+ func testBrowserPanelEnablesInspectableWebViewAndDeveloperExtras() {
+ let panel = BrowserPanel(workspaceId: UUID())
+ let developerExtras = panel.webView.configuration.preferences.value(forKey: "developerExtrasEnabled") as? Bool
+ XCTAssertEqual(developerExtras, true)
+
+ if #available(macOS 13.3, *) {
+ XCTAssertTrue(panel.webView.isInspectable)
+ }
+ }
+
+ func testBrowserPanelRefreshesUnderPageBackgroundColorWhenGhosttyBackgroundChanges() {
+ let panel = BrowserPanel(workspaceId: UUID())
+ let updatedColor = NSColor(srgbRed: 0.18, green: 0.29, blue: 0.44, alpha: 1.0)
+ let updatedOpacity = 0.57
+
+ NotificationCenter.default.post(
+ name: .ghosttyDefaultBackgroundDidChange,
+ object: nil,
+ userInfo: [
+ GhosttyNotificationKey.backgroundColor: updatedColor,
+ GhosttyNotificationKey.backgroundOpacity: updatedOpacity
+ ]
+ )
+
+ guard let actual = panel.webView.underPageBackgroundColor?.usingColorSpace(.sRGB),
+ let expected = updatedColor.withAlphaComponent(updatedOpacity).usingColorSpace(.sRGB) else {
+ XCTFail("Expected sRGB-convertible under-page background colors")
+ return
+ }
+
+ XCTAssertEqual(actual.redComponent, expected.redComponent, accuracy: 0.005)
+ XCTAssertEqual(actual.greenComponent, expected.greenComponent, accuracy: 0.005)
+ XCTAssertEqual(actual.blueComponent, expected.blueComponent, accuracy: 0.005)
+ XCTAssertEqual(actual.alphaComponent, expected.alphaComponent, accuracy: 0.005)
+ }
+
+ func testBrowserPanelStartsAsNewTabWithoutLoadingAboutBlank() {
+ let panel = BrowserPanel(workspaceId: UUID())
+
+ XCTAssertEqual(panel.displayTitle, "New tab")
+ XCTAssertFalse(panel.shouldRenderWebView)
+ XCTAssertTrue(panel.isShowingNewTabPage)
+ XCTAssertNil(panel.webView.url)
+ XCTAssertNil(panel.currentURL)
+ }
+
+ func testBrowserPanelLeavesNewTabPageStateWhenNavigationStarts() {
+ let panel = BrowserPanel(workspaceId: UUID())
+
+ XCTAssertTrue(panel.isShowingNewTabPage)
+ panel.navigate(to: URL(string: "https://example.com")!)
+ XCTAssertFalse(panel.isShowingNewTabPage)
+ }
+
+ func testBrowserPanelThemeModeUpdatesWebViewAppearance() {
+ let panel = BrowserPanel(workspaceId: UUID())
+
+ panel.setBrowserThemeMode(.dark)
+ XCTAssertEqual(panel.webView.appearance?.bestMatch(from: [.darkAqua, .aqua]), .darkAqua)
+
+ panel.setBrowserThemeMode(.light)
+ XCTAssertEqual(panel.webView.appearance?.bestMatch(from: [.aqua, .darkAqua]), .aqua)
+
+ panel.setBrowserThemeMode(.system)
+ XCTAssertNil(panel.webView.appearance)
+ }
+
+ func testBrowserPanelRefreshesUnderPageBackgroundColorWithGhosttyOpacity() {
+ let panel = BrowserPanel(workspaceId: UUID())
+ let updatedColor = NSColor(srgbRed: 0.18, green: 0.29, blue: 0.44, alpha: 1.0)
+
+ NotificationCenter.default.post(
+ name: .ghosttyDefaultBackgroundDidChange,
+ object: nil,
+ userInfo: [
+ GhosttyNotificationKey.backgroundColor: updatedColor,
+ GhosttyNotificationKey.backgroundOpacity: NSNumber(value: 0.57),
+ ]
+ )
+
+ guard let actual = panel.webView.underPageBackgroundColor?.usingColorSpace(.sRGB),
+ let expected = updatedColor.withAlphaComponent(0.57).usingColorSpace(.sRGB) else {
+ XCTFail("Expected sRGB-convertible under-page background colors")
+ return
+ }
+
+ XCTAssertEqual(actual.redComponent, expected.redComponent, accuracy: 0.005)
+ XCTAssertEqual(actual.greenComponent, expected.greenComponent, accuracy: 0.005)
+ XCTAssertEqual(actual.blueComponent, expected.blueComponent, accuracy: 0.005)
+ XCTAssertEqual(actual.alphaComponent, expected.alphaComponent, accuracy: 0.005)
+ }
+}
+
+
+@MainActor
+final class BrowserInsecureHTTPAlertPresentationTests: XCTestCase {
+ private final class BrowserInsecureHTTPAlertSpy: NSAlert {
+ private(set) var beginSheetModalCallCount = 0
+ private(set) var runModalCallCount = 0
+ var nextResponse: NSApplication.ModalResponse = .alertThirdButtonReturn
+
+ override func beginSheetModal(
+ for sheetWindow: NSWindow,
+ completionHandler handler: ((NSApplication.ModalResponse) -> Void)?
+ ) {
+ beginSheetModalCallCount += 1
+ handler?(nextResponse)
+ }
+
+ override func runModal() -> NSApplication.ModalResponse {
+ runModalCallCount += 1
+ return nextResponse
+ }
+ }
+
+ func testInsecureHTTPPromptUsesSheetWhenWindowIsAvailable() {
+ let panel = BrowserPanel(workspaceId: UUID())
+ defer { panel.resetInsecureHTTPAlertHooksForTesting() }
+
+ let alertSpy = BrowserInsecureHTTPAlertSpy()
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 480, height: 320),
+ styleMask: [.titled],
+ backing: .buffered,
+ defer: false
+ )
+
+ panel.configureInsecureHTTPAlertHooksForTesting(
+ alertFactory: { alertSpy },
+ windowProvider: { window }
+ )
+ panel.presentInsecureHTTPAlertForTesting(url: URL(string: "http://example.com")!)
+
+ XCTAssertEqual(alertSpy.beginSheetModalCallCount, 1)
+ XCTAssertEqual(alertSpy.runModalCallCount, 0)
+ }
+
+ func testInsecureHTTPPromptFallsBackToRunModalWithoutWindow() {
+ let panel = BrowserPanel(workspaceId: UUID())
+ defer { panel.resetInsecureHTTPAlertHooksForTesting() }
+
+ let alertSpy = BrowserInsecureHTTPAlertSpy()
+ panel.configureInsecureHTTPAlertHooksForTesting(
+ alertFactory: { alertSpy },
+ windowProvider: { nil }
+ )
+ panel.presentInsecureHTTPAlertForTesting(url: URL(string: "http://example.com")!)
+
+ XCTAssertEqual(alertSpy.beginSheetModalCallCount, 0)
+ XCTAssertEqual(alertSpy.runModalCallCount, 1)
+ }
+}
+
+
+final class BrowserNavigationNewTabDecisionTests: XCTestCase {
+ func testLinkActivatedCmdClickOpensInNewTab() {
+ XCTAssertTrue(
+ browserNavigationShouldOpenInNewTab(
+ navigationType: .linkActivated,
+ modifierFlags: [.command],
+ buttonNumber: 0
+ )
+ )
+ }
+
+ func testLinkActivatedMiddleClickOpensInNewTab() {
+ XCTAssertTrue(
+ browserNavigationShouldOpenInNewTab(
+ navigationType: .linkActivated,
+ modifierFlags: [],
+ buttonNumber: 2
+ )
+ )
+ }
+
+ func testLinkActivatedPlainLeftClickStaysInCurrentTab() {
+ XCTAssertFalse(
+ browserNavigationShouldOpenInNewTab(
+ navigationType: .linkActivated,
+ modifierFlags: [],
+ buttonNumber: 0
+ )
+ )
+ }
+
+ func testOtherNavigationMiddleClickOpensInNewTab() {
+ XCTAssertTrue(
+ browserNavigationShouldOpenInNewTab(
+ navigationType: .other,
+ modifierFlags: [],
+ buttonNumber: 2
+ )
+ )
+ }
+
+ func testOtherNavigationLeftClickStaysInCurrentTab() {
+ XCTAssertFalse(
+ browserNavigationShouldOpenInNewTab(
+ navigationType: .other,
+ modifierFlags: [],
+ buttonNumber: 0
+ )
+ )
+ }
+
+ func testLinkActivatedButtonFourWithoutMiddleIntentStaysInCurrentTab() {
+ XCTAssertFalse(
+ browserNavigationShouldOpenInNewTab(
+ navigationType: .linkActivated,
+ modifierFlags: [],
+ buttonNumber: 4,
+ hasRecentMiddleClickIntent: false
+ )
+ )
+ }
+
+ func testLinkActivatedButtonFourWithRecentMiddleIntentOpensInNewTab() {
+ XCTAssertTrue(
+ browserNavigationShouldOpenInNewTab(
+ navigationType: .linkActivated,
+ modifierFlags: [],
+ buttonNumber: 4,
+ hasRecentMiddleClickIntent: true
+ )
+ )
+ }
+
+ func testLinkActivatedUsesCurrentEventFallbackForMiddleClick() {
+ XCTAssertTrue(
+ browserNavigationShouldOpenInNewTab(
+ navigationType: .linkActivated,
+ modifierFlags: [],
+ buttonNumber: 0,
+ currentEventType: .otherMouseUp,
+ currentEventButtonNumber: 2
+ )
+ )
+ }
+
+ func testCurrentEventFallbackDoesNotAffectNonLinkNavigation() {
+ XCTAssertFalse(
+ browserNavigationShouldOpenInNewTab(
+ navigationType: .reload,
+ modifierFlags: [],
+ buttonNumber: 0,
+ currentEventType: .otherMouseUp,
+ currentEventButtonNumber: 2
+ )
+ )
+ }
+
+ func testNonLinkNavigationNeverForcesNewTab() {
+ XCTAssertFalse(
+ browserNavigationShouldOpenInNewTab(
+ navigationType: .reload,
+ modifierFlags: [.command],
+ buttonNumber: 2
+ )
+ )
+ }
+}
+
+
+final class BrowserPopupDecisionTests: XCTestCase {
+ func testLinkActivatedPlainLeftClickDoesNotCreatePopup() {
+ XCTAssertFalse(
+ browserNavigationShouldCreatePopup(
+ navigationType: .linkActivated,
+ modifierFlags: [],
+ buttonNumber: 0
+ )
+ )
+ }
+
+ func testOtherNavigationPlainLeftClickCreatesPopup() {
+ XCTAssertTrue(
+ browserNavigationShouldCreatePopup(
+ navigationType: .other,
+ modifierFlags: [],
+ buttonNumber: 0
+ )
+ )
+ }
+
+ func testOtherNavigationMiddleClickDoesNotCreatePopup() {
+ XCTAssertFalse(
+ browserNavigationShouldCreatePopup(
+ navigationType: .other,
+ modifierFlags: [],
+ buttonNumber: 2
+ )
+ )
+ }
+
+ func testLinkActivatedCmdClickDoesNotCreatePopup() {
+ XCTAssertFalse(
+ browserNavigationShouldCreatePopup(
+ navigationType: .linkActivated,
+ modifierFlags: [.command],
+ buttonNumber: 0
+ )
+ )
+ }
+}
+
+
+final class BrowserNilTargetFallbackDecisionTests: XCTestCase {
+ func testOtherNavigationDoesNotFallbackToNewTab() {
+ XCTAssertFalse(
+ browserNavigationShouldFallbackNilTargetToNewTab(
+ navigationType: .other
+ )
+ )
+ }
+
+ func testLinkActivatedNavigationFallsBackToNewTab() {
+ XCTAssertTrue(
+ browserNavigationShouldFallbackNilTargetToNewTab(
+ navigationType: .linkActivated
+ )
+ )
+ }
+}
+
+
+final class BrowserPopupContentRectTests: XCTestCase {
+ func testExplicitTopOriginCoordinatesConvertToAppKitBottomOrigin() {
+ let rect = browserPopupContentRect(
+ requestedWidth: 400,
+ requestedHeight: 300,
+ requestedX: 150,
+ requestedTopY: 120,
+ visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800)
+ )
+
+ XCTAssertEqual(rect.origin.x, 150, accuracy: 0.01)
+ XCTAssertEqual(rect.origin.y, 430, accuracy: 0.01)
+ XCTAssertEqual(rect.width, 400, accuracy: 0.01)
+ XCTAssertEqual(rect.height, 300, accuracy: 0.01)
+ }
+
+ func testExplicitCoordinatesClampToVisibleFrame() {
+ let rect = browserPopupContentRect(
+ requestedWidth: 1400,
+ requestedHeight: 1200,
+ requestedX: 900,
+ requestedTopY: -25,
+ visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800)
+ )
+
+ XCTAssertEqual(rect.origin.x, 100, accuracy: 0.01)
+ XCTAssertEqual(rect.origin.y, 50, accuracy: 0.01)
+ XCTAssertEqual(rect.width, 1000, accuracy: 0.01)
+ XCTAssertEqual(rect.height, 800, accuracy: 0.01)
+ }
+
+ func testMissingCoordinatesCentersPopup() {
+ let rect = browserPopupContentRect(
+ requestedWidth: 300,
+ requestedHeight: 200,
+ requestedX: nil,
+ requestedTopY: nil,
+ visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800)
+ )
+
+ XCTAssertEqual(rect.origin.x, 450, accuracy: 0.01)
+ XCTAssertEqual(rect.origin.y, 350, accuracy: 0.01)
+ XCTAssertEqual(rect.width, 300, accuracy: 0.01)
+ XCTAssertEqual(rect.height, 200, accuracy: 0.01)
+ }
+}
+
+
+@MainActor
+final class BrowserJavaScriptDialogDelegateTests: XCTestCase {
+ func testBrowserPanelUIDelegateImplementsJavaScriptDialogSelectors() {
+ let panel = BrowserPanel(workspaceId: UUID())
+ guard let uiDelegate = panel.webView.uiDelegate as? NSObject else {
+ XCTFail("Expected BrowserPanel webView.uiDelegate to be an NSObject")
+ return
+ }
+
+ XCTAssertTrue(
+ uiDelegate.responds(
+ to: #selector(
+ WKUIDelegate.webView(
+ _:runJavaScriptAlertPanelWithMessage:initiatedByFrame:completionHandler:
+ )
+ )
+ ),
+ "Browser UI delegate must implement JavaScript alert handling"
+ )
+ XCTAssertTrue(
+ uiDelegate.responds(
+ to: #selector(
+ WKUIDelegate.webView(
+ _:runJavaScriptConfirmPanelWithMessage:initiatedByFrame:completionHandler:
+ )
+ )
+ ),
+ "Browser UI delegate must implement JavaScript confirm handling"
+ )
+ XCTAssertTrue(
+ uiDelegate.responds(
+ to: #selector(
+ WKUIDelegate.webView(
+ _:runJavaScriptTextInputPanelWithPrompt:defaultText:initiatedByFrame:completionHandler:
+ )
+ )
+ ),
+ "Browser UI delegate must implement JavaScript prompt handling"
+ )
+ }
+}
+
+
+@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)
+ }
+
+ func testWebViewReplacementAfterProcessTerminationUpdatesInstanceIdentity() {
+ let panel = BrowserPanel(
+ workspaceId: UUID(),
+ initialURL: URL(string: "https://example.com")
+ )
+ let oldWebView = panel.webView
+ let oldInstanceID = panel.webViewInstanceID
+
+ panel.debugSimulateWebContentProcessTermination()
+
+ XCTAssertFalse(panel.webView === oldWebView)
+ XCTAssertNotEqual(panel.webViewInstanceID, oldInstanceID)
+ XCTAssertNotNil(panel.webView.navigationDelegate)
+ XCTAssertNotNil(panel.webView.uiDelegate)
+ }
+
+ func testWebViewReplacementPreservesEmptyNewTabRenderState() {
+ let panel = BrowserPanel(workspaceId: UUID())
+ XCTAssertFalse(panel.shouldRenderWebView)
+
+ panel.debugSimulateWebContentProcessTermination()
+
+ XCTAssertFalse(panel.shouldRenderWebView)
+ }
+
+ func testResetSidebarContextClearsBrowserPanelsIntoNewTabState() throws {
+ let workspace = Workspace()
+ let paneId = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first)
+ let contextPanelId = try XCTUnwrap(workspace.focusedPanelId)
+ let browser = try XCTUnwrap(
+ workspace.newBrowserSurface(
+ inPane: paneId,
+ url: URL(string: "https://example.com"),
+ focus: false
+ )
+ )
+
+ browser.restoreSessionNavigationHistory(
+ backHistoryURLStrings: ["https://example.com/prev"],
+ forwardHistoryURLStrings: ["https://example.com/next"],
+ currentURLString: "https://example.com/current"
+ )
+ browser.startFind()
+
+ workspace.statusEntries["task"] = SidebarStatusEntry(key: "task", value: "Issue #1208")
+ workspace.metadataBlocks["notes"] = SidebarMetadataBlock(
+ key: "notes",
+ markdown: "test",
+ priority: 0,
+ timestamp: Date()
+ )
+ workspace.progress = SidebarProgressState(value: 0.5, label: "Loading")
+ workspace.updatePanelGitBranch(panelId: contextPanelId, branch: "issue-1208", isDirty: false)
+ workspace.updatePanelPullRequest(
+ panelId: contextPanelId,
+ number: 1208,
+ label: "PR",
+ url: try XCTUnwrap(URL(string: "https://example.com/pull/1208")),
+ status: .open
+ )
+ workspace.logEntries.append(
+ SidebarLogEntry(
+ message: "Issue #1208",
+ level: .info,
+ source: "test",
+ timestamp: Date()
+ )
+ )
+ workspace.surfaceListeningPorts[contextPanelId] = [3000]
+ workspace.recomputeListeningPorts()
+
+ XCTAssertTrue(browser.shouldRenderWebView)
+ XCTAssertNotNil(browser.preferredURLStringForOmnibar())
+ XCTAssertTrue(browser.canGoBack)
+ XCTAssertTrue(browser.canGoForward)
+ XCTAssertNotNil(browser.searchState)
+ XCTAssertFalse(workspace.statusEntries.isEmpty)
+ XCTAssertFalse(workspace.logEntries.isEmpty)
+ XCTAssertFalse(workspace.metadataBlocks.isEmpty)
+ XCTAssertNotNil(workspace.progress)
+ XCTAssertNotNil(workspace.gitBranch)
+ XCTAssertNotNil(workspace.pullRequest)
+ XCTAssertEqual(workspace.listeningPorts, [3000])
+
+ let priorWebView = browser.webView
+ let priorInstanceID = browser.webViewInstanceID
+ workspace.resetSidebarContext(reason: "test")
+
+ XCTAssertTrue(workspace.statusEntries.isEmpty)
+ XCTAssertTrue(workspace.logEntries.isEmpty)
+ XCTAssertTrue(workspace.metadataBlocks.isEmpty)
+ XCTAssertNil(workspace.progress)
+ XCTAssertNil(workspace.gitBranch)
+ XCTAssertTrue(workspace.panelGitBranches.isEmpty)
+ XCTAssertNil(workspace.pullRequest)
+ XCTAssertTrue(workspace.panelPullRequests.isEmpty)
+ XCTAssertTrue(workspace.surfaceListeningPorts.isEmpty)
+ XCTAssertTrue(workspace.listeningPorts.isEmpty)
+ XCTAssertFalse(browser.shouldRenderWebView)
+ XCTAssertNil(browser.preferredURLStringForOmnibar())
+ XCTAssertFalse(browser.canGoBack)
+ XCTAssertFalse(browser.canGoForward)
+ XCTAssertNil(browser.searchState)
+ XCTAssertFalse(browser.webView === priorWebView)
+ XCTAssertNotEqual(browser.webViewInstanceID, priorInstanceID)
+ }
+
+}
+
+
+@MainActor
+final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
+ private final class WKInspectorProbeView: NSView {
+ override var acceptsFirstResponder: Bool { true }
+ }
+
+ private final class FakeInspector: NSObject {
+ enum HideBehavior {
+ case unsupported
+ case noEffect
+ case hides
+ }
+
+ private(set) var attachCount = 0
+ private(set) var showCount = 0
+ private(set) var hideCount = 0
+ private(set) var closeCount = 0
+ private let hideBehavior: HideBehavior
+ private var visible = false
+ private var attached = false
+
+ init(hideBehavior: HideBehavior = .unsupported) {
+ self.hideBehavior = hideBehavior
+ super.init()
+ }
+
+ override func responds(to aSelector: Selector!) -> Bool {
+ guard NSStringFromSelector(aSelector) == "hide" else {
+ return super.responds(to: aSelector)
+ }
+ return hideBehavior != .unsupported
+ }
+
+ @objc func isVisible() -> Bool {
+ visible
+ }
+
+ @objc func isAttached() -> Bool {
+ attached
+ }
+
+ @objc func attach() {
+ attachCount += 1
+ attached = true
+ show()
+ }
+
+ @objc func show() {
+ showCount += 1
+ visible = true
+ }
+
+ @objc func hide() {
+ hideCount += 1
+ guard hideBehavior == .hides else { return }
+ visible = false
+ }
+
+ @objc func close() {
+ closeCount += 1
+ visible = false
+ attached = false
+ }
+ }
+
+ override class func setUp() {
+ super.setUp()
+ installCmuxUnitTestInspectorOverride()
+ }
+
+ private func makePanelWithInspector(
+ hideBehavior: FakeInspector.HideBehavior = .unsupported
+ ) -> (BrowserPanel, FakeInspector) {
+ let panel = BrowserPanel(workspaceId: UUID())
+ let inspector = FakeInspector(hideBehavior: hideBehavior)
+ panel.webView.cmuxSetUnitTestInspector(inspector)
+ return (panel, inspector)
+ }
+
+ private func findHostContainerView(in root: NSView) -> WebViewRepresentable.HostContainerView? {
+ if let host = root as? WebViewRepresentable.HostContainerView {
+ return host
+ }
+ for subview in root.subviews {
+ if let host = findHostContainerView(in: subview) {
+ return host
+ }
+ }
+ return nil
+ }
+
+ private func waitForDeveloperToolsTransitions() {
+ RunLoop.current.run(until: Date().addingTimeInterval(0.5))
+ }
+
+ private func findWindowBrowserSlotView(in root: NSView) -> WindowBrowserSlotView? {
+ if let slot = root as? WindowBrowserSlotView {
+ return slot
+ }
+ for subview in root.subviews {
+ if let slot = findWindowBrowserSlotView(in: subview) {
+ return slot
+ }
+ }
+ return nil
+ }
+
+ func testRestoreReopensInspectorAfterAttachWhenPreferredVisible() {
+ let (panel, inspector) = makePanelWithInspector()
+
+ XCTAssertTrue(panel.showDeveloperTools())
+ XCTAssertTrue(panel.isDeveloperToolsVisible())
+ XCTAssertEqual(inspector.showCount, 1)
+
+ // Simulate WebKit closing inspector during detach/reattach churn.
+ inspector.close()
+ XCTAssertFalse(panel.isDeveloperToolsVisible())
+ XCTAssertEqual(inspector.closeCount, 1)
+
+ panel.restoreDeveloperToolsAfterAttachIfNeeded()
+ XCTAssertTrue(panel.isDeveloperToolsVisible())
+ XCTAssertEqual(inspector.showCount, 2)
+ }
+
+ func testSyncRespectsManualCloseAndPreventsUnexpectedRestore() {
+ let (panel, inspector) = makePanelWithInspector()
+
+ XCTAssertTrue(panel.showDeveloperTools())
+ XCTAssertEqual(inspector.showCount, 1)
+
+ // Simulate user closing inspector before detach.
+ inspector.close()
+ panel.syncDeveloperToolsPreferenceFromInspector()
+
+ panel.restoreDeveloperToolsAfterAttachIfNeeded()
+ XCTAssertFalse(panel.isDeveloperToolsVisible())
+ XCTAssertEqual(inspector.showCount, 1)
+ }
+
+ func testSyncCanPreserveVisibleIntentDuringDetachChurn() {
+ let (panel, inspector) = makePanelWithInspector()
+
+ XCTAssertTrue(panel.showDeveloperTools())
+ XCTAssertEqual(inspector.showCount, 1)
+
+ // Simulate a transient close caused by view detach, not user intent.
+ inspector.close()
+ panel.syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: true)
+ panel.restoreDeveloperToolsAfterAttachIfNeeded()
+
+ XCTAssertTrue(panel.isDeveloperToolsVisible())
+ XCTAssertEqual(inspector.showCount, 2)
+ }
+
+ func testForcedRefreshAfterAttachKeepsVisibleInspectorState() {
+ let (panel, inspector) = makePanelWithInspector()
+
+ XCTAssertTrue(panel.showDeveloperTools())
+ XCTAssertTrue(panel.isDeveloperToolsVisible())
+ XCTAssertEqual(inspector.showCount, 1)
+ XCTAssertEqual(inspector.closeCount, 0)
+
+ panel.requestDeveloperToolsRefreshAfterNextAttach(reason: "unit-test")
+ panel.restoreDeveloperToolsAfterAttachIfNeeded()
+
+ XCTAssertTrue(panel.isDeveloperToolsVisible())
+ XCTAssertEqual(inspector.closeCount, 0)
+ XCTAssertEqual(inspector.showCount, 1)
+
+ // The force-refresh request should be one-shot.
+ panel.restoreDeveloperToolsAfterAttachIfNeeded()
+ XCTAssertEqual(inspector.closeCount, 0)
+ XCTAssertEqual(inspector.showCount, 1)
+ }
+
+ func testRefreshRequestTracksPendingStateUntilRestoreRuns() {
+ let (panel, _) = makePanelWithInspector()
+
+ XCTAssertTrue(panel.showDeveloperTools())
+ XCTAssertFalse(panel.hasPendingDeveloperToolsRefreshAfterAttach())
+
+ panel.requestDeveloperToolsRefreshAfterNextAttach(reason: "unit-test")
+ XCTAssertTrue(panel.hasPendingDeveloperToolsRefreshAfterAttach())
+
+ panel.restoreDeveloperToolsAfterAttachIfNeeded()
+ XCTAssertFalse(panel.hasPendingDeveloperToolsRefreshAfterAttach())
+ }
+
+ func testRapidToggleCoalescesToFinalVisibleIntentWithoutExtraInspectorCalls() {
+ let (panel, inspector) = makePanelWithInspector()
+
+ XCTAssertTrue(panel.toggleDeveloperTools())
+ XCTAssertTrue(panel.toggleDeveloperTools())
+ XCTAssertTrue(panel.toggleDeveloperTools())
+ XCTAssertEqual(inspector.showCount, 1)
+ XCTAssertEqual(inspector.closeCount, 0)
+
+ waitForDeveloperToolsTransitions()
+
+ XCTAssertTrue(panel.isDeveloperToolsVisible())
+ XCTAssertEqual(inspector.showCount, 1)
+ XCTAssertEqual(inspector.closeCount, 0)
+ }
+
+ func testRapidToggleQueuesHideAfterOpenTransitionSettles() {
+ let (panel, inspector) = makePanelWithInspector()
+
+ XCTAssertTrue(panel.toggleDeveloperTools())
+ XCTAssertTrue(panel.toggleDeveloperTools())
+ XCTAssertEqual(inspector.showCount, 1)
+ XCTAssertEqual(inspector.closeCount, 0)
+
+ waitForDeveloperToolsTransitions()
+
+ XCTAssertFalse(panel.isDeveloperToolsVisible())
+ XCTAssertEqual(inspector.showCount, 1)
+ XCTAssertEqual(inspector.closeCount, 1)
+ }
+
+ func testToggleDeveloperToolsFallsBackToCloseWhenHideDoesNotConcealInspector() {
+ let (panel, inspector) = makePanelWithInspector(hideBehavior: .noEffect)
+
+ XCTAssertTrue(panel.showDeveloperTools())
+ XCTAssertTrue(panel.isDeveloperToolsVisible())
+
+ XCTAssertTrue(panel.toggleDeveloperTools())
+
+ XCTAssertEqual(inspector.hideCount, 1)
+ XCTAssertEqual(inspector.closeCount, 1)
+ XCTAssertFalse(panel.isDeveloperToolsVisible())
+ }
+
+ func testTransientHideAttachmentPreserveFollowsDeveloperToolsIntent() {
+ let (panel, _) = makePanelWithInspector()
+
+ XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
+ XCTAssertTrue(panel.showDeveloperTools())
+ XCTAssertTrue(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
+ XCTAssertTrue(panel.hideDeveloperTools())
+ XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
+ }
+
+ func testWebViewDismantleKeepsPortalHostedWebViewAttachedWhenDeveloperToolsIntentIsVisible() {
+ let (panel, _) = makePanelWithInspector()
+ let paneId = PaneID(id: UUID())
+ XCTAssertTrue(panel.showDeveloperTools())
+
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ let anchor = NSView(frame: NSRect(x: 30, y: 30, width: 180, height: 140))
+ window.contentView?.addSubview(anchor)
+ window.makeKeyAndOrderFront(nil)
+ window.displayIfNeeded()
+ window.contentView?.layoutSubtreeIfNeeded()
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+
+ BrowserWindowPortalRegistry.bind(webView: panel.webView, to: anchor, visibleInUI: true, zPriority: 1)
+ BrowserWindowPortalRegistry.synchronizeForAnchor(anchor)
+ XCTAssertNotNil(panel.webView.superview)
+
+ let representable = WebViewRepresentable(
+ panel: panel,
+ paneId: paneId,
+ shouldAttachWebView: true,
+ useLocalInlineHosting: false,
+ shouldFocusWebView: false,
+ isPanelFocused: true,
+ portalZPriority: 0,
+ paneDropZone: nil,
+ searchOverlay: nil,
+ paneTopChromeHeight: 0
+ )
+ let coordinator = representable.makeCoordinator()
+ coordinator.webView = panel.webView
+ WebViewRepresentable.dismantleNSView(anchor, coordinator: coordinator)
+
+ XCTAssertNotNil(panel.webView.superview)
+ window.orderOut(nil)
+ }
+
+ func testWebViewDismantleKeepsPortalHostedWebViewAttachedWhenDeveloperToolsIntentIsHidden() {
+ let (panel, _) = makePanelWithInspector()
+ let paneId = PaneID(id: UUID())
+ XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
+
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ let anchor = NSView(frame: NSRect(x: 20, y: 20, width: 200, height: 150))
+ window.contentView?.addSubview(anchor)
+ window.makeKeyAndOrderFront(nil)
+ window.displayIfNeeded()
+ window.contentView?.layoutSubtreeIfNeeded()
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+
+ BrowserWindowPortalRegistry.bind(webView: panel.webView, to: anchor, visibleInUI: true, zPriority: 1)
+ BrowserWindowPortalRegistry.synchronizeForAnchor(anchor)
+ XCTAssertNotNil(panel.webView.superview)
+
+ let representable = WebViewRepresentable(
+ panel: panel,
+ paneId: paneId,
+ shouldAttachWebView: true,
+ useLocalInlineHosting: false,
+ shouldFocusWebView: false,
+ isPanelFocused: true,
+ portalZPriority: 0,
+ paneDropZone: nil,
+ searchOverlay: nil,
+ paneTopChromeHeight: 0
+ )
+ let coordinator = representable.makeCoordinator()
+ coordinator.webView = panel.webView
+ WebViewRepresentable.dismantleNSView(anchor, coordinator: coordinator)
+
+ XCTAssertNotNil(panel.webView.superview)
+ window.orderOut(nil)
+ }
+
+ func testTransientHideAttachmentPreserveDisablesForSideDockedInspectorLayout() {
+ let (panel, _) = makePanelWithInspector()
+ XCTAssertTrue(panel.showDeveloperTools())
+
+ let host = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 240))
+ panel.webView.frame = NSRect(x: 0, y: 0, width: 120, height: host.bounds.height)
+ host.addSubview(panel.webView)
+
+ let inspectorContainer = NSView(
+ frame: NSRect(x: 120, y: 0, width: host.bounds.width - 120, height: host.bounds.height)
+ )
+ let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
+ inspectorView.autoresizingMask = [.width, .height]
+ inspectorContainer.addSubview(inspectorView)
+ host.addSubview(inspectorContainer)
+
+ XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
+ }
+
+ func testTransientHideAttachmentPreserveStaysEnabledForBottomDockedInspectorLayout() {
+ let (panel, _) = makePanelWithInspector()
+ XCTAssertTrue(panel.showDeveloperTools())
+
+ let host = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 240))
+ panel.webView.frame = NSRect(x: 0, y: 80, width: host.bounds.width, height: host.bounds.height - 80)
+ host.addSubview(panel.webView)
+
+ let inspectorContainer = NSView(frame: NSRect(x: 0, y: 0, width: host.bounds.width, height: 80))
+ let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
+ inspectorView.autoresizingMask = [.width, .height]
+ inspectorContainer.addSubview(inspectorView)
+ host.addSubview(inspectorContainer)
+
+ XCTAssertTrue(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
+ }
+
+ func testOffWindowReplacementLocalHostDoesNotStealVisibleDevToolsWebView() {
+ let (panel, _) = makePanelWithInspector()
+ XCTAssertTrue(panel.showDeveloperTools())
+
+ let paneId = PaneID(id: UUID())
+ let representable = WebViewRepresentable(
+ panel: panel,
+ paneId: paneId,
+ shouldAttachWebView: false,
+ useLocalInlineHosting: true,
+ shouldFocusWebView: false,
+ isPanelFocused: true,
+ portalZPriority: 0,
+ paneDropZone: nil,
+ searchOverlay: nil,
+ paneTopChromeHeight: 0
+ )
+
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let visibleHosting = NSHostingView(rootView: representable)
+ visibleHosting.frame = contentView.bounds
+ visibleHosting.autoresizingMask = [.width, .height]
+ contentView.addSubview(visibleHosting)
+ window.makeKeyAndOrderFront(nil)
+ window.displayIfNeeded()
+ contentView.layoutSubtreeIfNeeded()
+ visibleHosting.layoutSubtreeIfNeeded()
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+
+ guard let visibleHost = findHostContainerView(in: visibleHosting) else {
+ XCTFail("Expected visible local host")
+ return
+ }
+ guard let visibleSlot = panel.webView.superview as? WindowBrowserSlotView else {
+ XCTFail("Expected visible local inline slot")
+ return
+ }
+
+ let inspectorView = WKInspectorProbeView(
+ frame: NSRect(x: 0, y: 0, width: visibleSlot.bounds.width, height: 72)
+ )
+ inspectorView.autoresizingMask = [.width]
+ visibleSlot.addSubview(inspectorView)
+ panel.webView.frame = NSRect(
+ x: 0,
+ y: inspectorView.frame.maxY,
+ width: visibleSlot.bounds.width,
+ height: visibleSlot.bounds.height - inspectorView.frame.height
+ )
+ visibleSlot.layoutSubtreeIfNeeded()
+
+ let detachedRoot = NSView(frame: visibleHosting.frame)
+ let offWindowHosting = NSHostingView(rootView: representable)
+ offWindowHosting.frame = detachedRoot.bounds
+ offWindowHosting.autoresizingMask = [.width, .height]
+ detachedRoot.addSubview(offWindowHosting)
+ detachedRoot.layoutSubtreeIfNeeded()
+ offWindowHosting.layoutSubtreeIfNeeded()
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+
+ XCTAssertNotNil(findHostContainerView(in: offWindowHosting), "Expected off-window replacement host")
+ XCTAssertTrue(visibleHost.window === window)
+ XCTAssertTrue(
+ panel.webView.superview === visibleSlot,
+ "An off-window replacement host should not steal a visible DevTools-hosted web view during split zoom churn"
+ )
+ XCTAssertTrue(
+ inspectorView.superview === visibleSlot,
+ "An off-window replacement host should leave DevTools companion views in the visible local host"
+ )
+ }
+
+ func testVisibleReplacementLocalHostNormalizesBottomDockedInspectorFrames() {
+ let (panel, _) = makePanelWithInspector()
+ XCTAssertTrue(panel.showDeveloperTools())
+
+ let paneId = PaneID(id: UUID())
+ let representable = WebViewRepresentable(
+ panel: panel,
+ paneId: paneId,
+ shouldAttachWebView: false,
+ useLocalInlineHosting: true,
+ shouldFocusWebView: false,
+ isPanelFocused: true,
+ portalZPriority: 0,
+ paneDropZone: nil,
+ searchOverlay: nil,
+ paneTopChromeHeight: 0
+ )
+
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let narrowHosting = NSHostingView(rootView: representable)
+ narrowHosting.frame = NSRect(x: 180, y: 0, width: 180, height: 240)
+ contentView.addSubview(narrowHosting)
+
+ window.makeKeyAndOrderFront(nil)
+ window.displayIfNeeded()
+ contentView.layoutSubtreeIfNeeded()
+ narrowHosting.layoutSubtreeIfNeeded()
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+
+ guard let initialSlot = panel.webView.superview as? WindowBrowserSlotView else {
+ XCTFail("Expected initial local inline slot")
+ return
+ }
+
+ let inspectorView = WKInspectorProbeView(
+ frame: NSRect(x: 0, y: 0, width: initialSlot.bounds.width, height: 72)
+ )
+ inspectorView.autoresizingMask = [.width]
+ initialSlot.addSubview(inspectorView)
+ panel.webView.frame = NSRect(
+ x: 0,
+ y: inspectorView.frame.maxY,
+ width: initialSlot.bounds.width,
+ height: initialSlot.bounds.height - inspectorView.frame.height
+ )
+ initialSlot.layoutSubtreeIfNeeded()
+
+ let replacementHosting = NSHostingView(rootView: representable)
+ replacementHosting.frame = contentView.bounds
+ replacementHosting.autoresizingMask = [.width, .height]
+ contentView.addSubview(replacementHosting, positioned: .above, relativeTo: narrowHosting)
+ contentView.layoutSubtreeIfNeeded()
+ replacementHosting.layoutSubtreeIfNeeded()
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+
+ replacementHosting.rootView = representable
+ contentView.layoutSubtreeIfNeeded()
+ replacementHosting.layoutSubtreeIfNeeded()
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+
+ narrowHosting.removeFromSuperview()
+ contentView.layoutSubtreeIfNeeded()
+ replacementHosting.layoutSubtreeIfNeeded()
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+
+ guard let replacementHost = findHostContainerView(in: replacementHosting),
+ let replacementSlot = findWindowBrowserSlotView(in: replacementHost) else {
+ XCTFail("Expected replacement local inline host")
+ return
+ }
+
+ XCTAssertTrue(
+ panel.webView.superview === replacementSlot,
+ "A visible replacement local host should take over the hosted page"
+ )
+ XCTAssertTrue(
+ inspectorView.superview === replacementSlot,
+ "A visible replacement local host should move the DevTools companion views with the page"
+ )
+ XCTAssertEqual(inspectorView.frame.minX, 0, accuracy: 0.5)
+ XCTAssertEqual(inspectorView.frame.minY, 0, accuracy: 0.5)
+ XCTAssertEqual(inspectorView.frame.width, replacementSlot.bounds.width, accuracy: 0.5)
+ XCTAssertEqual(inspectorView.frame.height, 72, accuracy: 0.5)
+ XCTAssertEqual(panel.webView.frame.minX, 0, accuracy: 0.5)
+ XCTAssertEqual(panel.webView.frame.minY, 72, accuracy: 0.5)
+ XCTAssertEqual(panel.webView.frame.width, replacementSlot.bounds.width, accuracy: 0.5)
+ XCTAssertEqual(panel.webView.frame.height, replacementSlot.bounds.height - 72, accuracy: 0.5)
+ }
+}
+
+
+final class BrowserOmnibarCommandNavigationTests: XCTestCase {
+ func testArrowNavigationDeltaRequiresFocusedAddressBarAndNoModifierFlags() {
+ XCTAssertNil(
+ browserOmnibarSelectionDeltaForArrowNavigation(
+ hasFocusedAddressBar: false,
+ flags: [],
+ keyCode: 126
+ )
+ )
+ XCTAssertNil(
+ browserOmnibarSelectionDeltaForArrowNavigation(
+ hasFocusedAddressBar: true,
+ flags: [.command],
+ keyCode: 126
+ )
+ )
+ XCTAssertEqual(
+ browserOmnibarSelectionDeltaForArrowNavigation(
+ hasFocusedAddressBar: true,
+ flags: [],
+ keyCode: 126
+ ),
+ -1
+ )
+ XCTAssertEqual(
+ browserOmnibarSelectionDeltaForArrowNavigation(
+ hasFocusedAddressBar: true,
+ flags: [],
+ keyCode: 125
+ ),
+ 1
+ )
+ }
+
+ func testArrowNavigationDeltaIgnoresCapsLockModifier() {
+ XCTAssertEqual(
+ browserOmnibarSelectionDeltaForArrowNavigation(
+ hasFocusedAddressBar: true,
+ flags: [.capsLock],
+ keyCode: 126
+ ),
+ -1
+ )
+ XCTAssertEqual(
+ browserOmnibarSelectionDeltaForArrowNavigation(
+ hasFocusedAddressBar: true,
+ flags: [.capsLock],
+ keyCode: 125
+ ),
+ 1
+ )
+ }
+
+ func testCommandNavigationDeltaRequiresFocusedAddressBarAndCommandOrControlOnly() {
+ XCTAssertNil(
+ browserOmnibarSelectionDeltaForCommandNavigation(
+ hasFocusedAddressBar: false,
+ flags: [.command],
+ chars: "n"
+ )
+ )
+
+ XCTAssertEqual(
+ browserOmnibarSelectionDeltaForCommandNavigation(
+ hasFocusedAddressBar: true,
+ flags: [.command],
+ chars: "n"
+ ),
+ 1
+ )
+
+ XCTAssertEqual(
+ browserOmnibarSelectionDeltaForCommandNavigation(
+ hasFocusedAddressBar: true,
+ flags: [.command],
+ chars: "p"
+ ),
+ -1
+ )
+
+ XCTAssertNil(
+ browserOmnibarSelectionDeltaForCommandNavigation(
+ hasFocusedAddressBar: true,
+ flags: [.command, .shift],
+ chars: "n"
+ )
+ )
+
+ XCTAssertEqual(
+ browserOmnibarSelectionDeltaForCommandNavigation(
+ hasFocusedAddressBar: true,
+ flags: [.control],
+ chars: "p"
+ ),
+ -1
+ )
+
+ XCTAssertEqual(
+ browserOmnibarSelectionDeltaForCommandNavigation(
+ hasFocusedAddressBar: true,
+ flags: [.control],
+ chars: "n"
+ ),
+ 1
+ )
+ }
+
+ func testCommandNavigationDeltaIgnoresCapsLockModifier() {
+ XCTAssertEqual(
+ browserOmnibarSelectionDeltaForCommandNavigation(
+ hasFocusedAddressBar: true,
+ flags: [.control, .capsLock],
+ chars: "n"
+ ),
+ 1
+ )
+ XCTAssertEqual(
+ browserOmnibarSelectionDeltaForCommandNavigation(
+ hasFocusedAddressBar: true,
+ flags: [.command, .capsLock],
+ chars: "p"
+ ),
+ -1
+ )
+ }
+
+ func testSubmitOnReturnIgnoresCapsLockModifier() {
+ XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: []))
+ XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.shift]))
+ XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.capsLock]))
+ XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.shift, .capsLock]))
+ XCTAssertFalse(browserOmnibarShouldSubmitOnReturn(flags: [.command, .capsLock]))
+ }
+}
+
+
+final class BrowserReturnKeyDownRoutingTests: XCTestCase {
+ func testRoutesForReturnWhenBrowserFirstResponder() {
+ XCTAssertTrue(
+ shouldDispatchBrowserReturnViaFirstResponderKeyDown(
+ keyCode: 36,
+ firstResponderIsBrowser: true,
+ flags: []
+ )
+ )
+ }
+
+ func testRoutesForKeypadEnterWhenBrowserFirstResponder() {
+ XCTAssertTrue(
+ shouldDispatchBrowserReturnViaFirstResponderKeyDown(
+ keyCode: 76,
+ firstResponderIsBrowser: true,
+ flags: []
+ )
+ )
+ }
+
+ func testDoesNotRouteForNonEnterKey() {
+ XCTAssertFalse(
+ shouldDispatchBrowserReturnViaFirstResponderKeyDown(
+ keyCode: 13,
+ firstResponderIsBrowser: true,
+ flags: []
+ )
+ )
+ }
+
+ func testDoesNotRouteWhenFirstResponderIsNotBrowser() {
+ XCTAssertFalse(
+ shouldDispatchBrowserReturnViaFirstResponderKeyDown(
+ keyCode: 36,
+ firstResponderIsBrowser: false,
+ flags: []
+ )
+ )
+ }
+
+ func testRoutesForShiftReturnWhenBrowserFirstResponder() {
+ XCTAssertTrue(
+ shouldDispatchBrowserReturnViaFirstResponderKeyDown(
+ keyCode: 36,
+ firstResponderIsBrowser: true,
+ flags: [.shift]
+ )
+ )
+ }
+
+ func testDoesNotRouteForCommandShiftReturnWhenBrowserFirstResponder() {
+ XCTAssertFalse(
+ shouldDispatchBrowserReturnViaFirstResponderKeyDown(
+ keyCode: 36,
+ firstResponderIsBrowser: true,
+ flags: [.command, .shift]
+ )
+ )
+ }
+
+ func testDoesNotRouteForCommandReturnWhenBrowserFirstResponder() {
+ XCTAssertFalse(
+ shouldDispatchBrowserReturnViaFirstResponderKeyDown(
+ keyCode: 36,
+ firstResponderIsBrowser: true,
+ flags: [.command]
+ )
+ )
+ }
+
+ func testDoesNotRouteForOptionReturnWhenBrowserFirstResponder() {
+ XCTAssertFalse(
+ shouldDispatchBrowserReturnViaFirstResponderKeyDown(
+ keyCode: 36,
+ firstResponderIsBrowser: true,
+ flags: [.option]
+ )
+ )
+ }
+
+ func testDoesNotRouteForControlReturnWhenBrowserFirstResponder() {
+ XCTAssertFalse(
+ shouldDispatchBrowserReturnViaFirstResponderKeyDown(
+ keyCode: 36,
+ firstResponderIsBrowser: true,
+ flags: [.control]
+ )
+ )
+ }
+}
+
+
+final class BrowserZoomShortcutActionTests: XCTestCase {
+ func testZoomInSupportsEqualsAndPlusVariants() {
+ XCTAssertEqual(
+ browserZoomShortcutAction(flags: [.command], chars: "=", keyCode: 24),
+ .zoomIn
+ )
+ XCTAssertEqual(
+ browserZoomShortcutAction(flags: [.command], chars: "+", keyCode: 24),
+ .zoomIn
+ )
+ XCTAssertEqual(
+ browserZoomShortcutAction(flags: [.command, .shift], chars: "+", keyCode: 24),
+ .zoomIn
+ )
+ XCTAssertEqual(
+ browserZoomShortcutAction(flags: [.command], chars: "+", keyCode: 30),
+ .zoomIn
+ )
+ }
+
+ func testZoomOutSupportsMinusAndUnderscoreVariants() {
+ XCTAssertEqual(
+ browserZoomShortcutAction(flags: [.command], chars: "-", keyCode: 27),
+ .zoomOut
+ )
+ XCTAssertEqual(
+ browserZoomShortcutAction(flags: [.command, .shift], chars: "_", keyCode: 27),
+ .zoomOut
+ )
+ }
+
+ func testZoomInSupportsShiftedLiteralFromDifferentPhysicalKey() {
+ XCTAssertEqual(
+ browserZoomShortcutAction(
+ flags: [.command, .shift],
+ chars: ";",
+ keyCode: 41,
+ literalChars: "+"
+ ),
+ .zoomIn
+ )
+
+ XCTAssertNil(
+ browserZoomShortcutAction(
+ flags: [.command, .shift],
+ chars: ";",
+ keyCode: 41
+ )
+ )
+ }
+
+ func testZoomRequiresCommandWithoutOptionOrControl() {
+ XCTAssertNil(browserZoomShortcutAction(flags: [], chars: "=", keyCode: 24))
+ XCTAssertNil(browserZoomShortcutAction(flags: [.command, .option], chars: "=", keyCode: 24))
+ XCTAssertNil(browserZoomShortcutAction(flags: [.command, .control], chars: "-", keyCode: 27))
+ }
+
+ func testResetSupportsCommandZero() {
+ XCTAssertEqual(
+ browserZoomShortcutAction(flags: [.command], chars: "0", keyCode: 29),
+ .reset
+ )
+ }
+}
+
+
+final class BrowserZoomShortcutRoutingPolicyTests: XCTestCase {
+ func testRoutesWhenGhosttyIsFirstResponderAndShortcutIsZoom() {
+ XCTAssertTrue(
+ shouldRouteTerminalFontZoomShortcutToGhostty(
+ firstResponderIsGhostty: true,
+ flags: [.command],
+ chars: "=",
+ keyCode: 24
+ )
+ )
+ XCTAssertTrue(
+ shouldRouteTerminalFontZoomShortcutToGhostty(
+ firstResponderIsGhostty: true,
+ flags: [.command],
+ chars: "-",
+ keyCode: 27
+ )
+ )
+ XCTAssertTrue(
+ shouldRouteTerminalFontZoomShortcutToGhostty(
+ firstResponderIsGhostty: true,
+ flags: [.command],
+ chars: "0",
+ keyCode: 29
+ )
+ )
+ }
+
+ func testDoesNotRouteWhenFirstResponderIsNotGhostty() {
+ XCTAssertFalse(
+ shouldRouteTerminalFontZoomShortcutToGhostty(
+ firstResponderIsGhostty: false,
+ flags: [.command],
+ chars: "=",
+ keyCode: 24
+ )
+ )
+ }
+
+ func testDoesNotRouteForNonZoomShortcuts() {
+ XCTAssertFalse(
+ shouldRouteTerminalFontZoomShortcutToGhostty(
+ firstResponderIsGhostty: true,
+ flags: [.command],
+ chars: "n",
+ keyCode: 45
+ )
+ )
+ }
+
+ func testRoutesForShiftedLiteralZoomShortcut() {
+ XCTAssertTrue(
+ shouldRouteTerminalFontZoomShortcutToGhostty(
+ firstResponderIsGhostty: true,
+ flags: [.command, .shift],
+ chars: ";",
+ keyCode: 41,
+ literalChars: "+"
+ )
+ )
+ }
+}
+
+
+final class BrowserSearchEngineTests: XCTestCase {
+ func testGoogleSearchURL() throws {
+ let url = try XCTUnwrap(BrowserSearchEngine.google.searchURL(query: "hello world"))
+ XCTAssertEqual(url.host, "www.google.com")
+ XCTAssertEqual(url.path, "/search")
+ XCTAssertTrue(url.absoluteString.contains("q=hello%20world"))
+ }
+
+ func testDuckDuckGoSearchURL() throws {
+ let url = try XCTUnwrap(BrowserSearchEngine.duckduckgo.searchURL(query: "hello world"))
+ XCTAssertEqual(url.host, "duckduckgo.com")
+ XCTAssertEqual(url.path, "/")
+ XCTAssertTrue(url.absoluteString.contains("q=hello%20world"))
+ }
+
+ func testBingSearchURL() throws {
+ let url = try XCTUnwrap(BrowserSearchEngine.bing.searchURL(query: "hello world"))
+ XCTAssertEqual(url.host, "www.bing.com")
+ XCTAssertEqual(url.path, "/search")
+ XCTAssertTrue(url.absoluteString.contains("q=hello%20world"))
+ }
+}
+
+
+final class BrowserSearchSettingsTests: XCTestCase {
+ func testCurrentSearchSuggestionsEnabledDefaultsToTrueWhenUnset() {
+ let suiteName = "BrowserSearchSettingsTests.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer {
+ defaults.removePersistentDomain(forName: suiteName)
+ }
+
+ defaults.removeObject(forKey: BrowserSearchSettings.searchSuggestionsEnabledKey)
+ XCTAssertTrue(BrowserSearchSettings.currentSearchSuggestionsEnabled(defaults: defaults))
+ }
+
+ func testCurrentSearchSuggestionsEnabledHonorsExplicitValue() {
+ let suiteName = "BrowserSearchSettingsTests.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer {
+ defaults.removePersistentDomain(forName: suiteName)
+ }
+
+ defaults.set(false, forKey: BrowserSearchSettings.searchSuggestionsEnabledKey)
+ XCTAssertFalse(BrowserSearchSettings.currentSearchSuggestionsEnabled(defaults: defaults))
+
+ defaults.set(true, forKey: BrowserSearchSettings.searchSuggestionsEnabledKey)
+ XCTAssertTrue(BrowserSearchSettings.currentSearchSuggestionsEnabled(defaults: defaults))
+ }
+}
+
+
+final class BrowserHistoryStoreTests: XCTestCase {
+ func testRecordVisitDedupesAndSuggests() async throws {
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent("BrowserHistoryStoreTests-\(UUID().uuidString)", isDirectory: true)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ defer {
+ try? FileManager.default.removeItem(at: tempDir)
+ }
+
+ let fileURL = tempDir.appendingPathComponent("browser_history.json")
+ let store = await MainActor.run { BrowserHistoryStore(fileURL: fileURL) }
+
+ let u1 = try XCTUnwrap(URL(string: "https://example.com/foo"))
+ let u2 = try XCTUnwrap(URL(string: "https://example.com/bar"))
+
+ await MainActor.run {
+ store.recordVisit(url: u1, title: "Example Foo")
+ store.recordVisit(url: u2, title: "Example Bar")
+ store.recordVisit(url: u1, title: "Example Foo Updated")
+ }
+
+ let suggestions = await MainActor.run { store.suggestions(for: "foo", limit: 10) }
+ XCTAssertEqual(suggestions.first?.url, "https://example.com/foo")
+ XCTAssertEqual(suggestions.first?.visitCount, 2)
+ XCTAssertEqual(suggestions.first?.title, "Example Foo Updated")
+ }
+
+ func testSuggestionsLoadsPersistedHistoryImmediatelyOnFirstQuery() async throws {
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent("BrowserHistoryStoreTests-\(UUID().uuidString)", isDirectory: true)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ defer {
+ try? FileManager.default.removeItem(at: tempDir)
+ }
+
+ let fileURL = tempDir.appendingPathComponent("browser_history.json")
+ let now = Date()
+ let seededEntries = [
+ BrowserHistoryStore.Entry(
+ id: UUID(),
+ url: "https://go.dev/",
+ title: "The Go Programming Language",
+ lastVisited: now,
+ visitCount: 3
+ ),
+ BrowserHistoryStore.Entry(
+ id: UUID(),
+ url: "https://www.google.com/",
+ title: "Google",
+ lastVisited: now.addingTimeInterval(-120),
+ visitCount: 2
+ ),
+ ]
+
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.withoutEscapingSlashes]
+ let data = try encoder.encode(seededEntries)
+ try data.write(to: fileURL, options: [.atomic])
+
+ let store = await MainActor.run { BrowserHistoryStore(fileURL: fileURL) }
+ let suggestions = await MainActor.run { store.suggestions(for: "go", limit: 10) }
+
+ XCTAssertGreaterThanOrEqual(suggestions.count, 2)
+ XCTAssertEqual(suggestions.first?.url, "https://go.dev/")
+ XCTAssertTrue(suggestions.contains(where: { $0.url == "https://www.google.com/" }))
+ }
+}
+
+
+@MainActor
+final class CmuxWebViewDragRoutingTests: XCTestCase {
+ func testRejectsInternalPaneDragEvenWhenFilePromiseTypesArePresent() {
+ XCTAssertTrue(
+ CmuxWebView.shouldRejectInternalPaneDrag([
+ DragOverlayRoutingPolicy.bonsplitTabTransferType,
+ NSPasteboard.PasteboardType("com.apple.pasteboard.promised-file-url"),
+ ])
+ )
+ }
+
+ func testAllowsRegularExternalFileDrops() {
+ XCTAssertFalse(CmuxWebView.shouldRejectInternalPaneDrag([.fileURL]))
+ }
+}
+
+#if compiler(>=6.2)
+@available(macOS 26.0, *)
+private struct DragConfigurationOperationsSnapshot: Equatable {
+ let allowCopy: Bool
+ let allowMove: Bool
+ let allowDelete: Bool
+ let allowAlias: Bool
+}
+
+@available(macOS 26.0, *)
+private enum DragConfigurationSnapshotError: Error {
+ case missingBoolField(primary: String, fallback: String?)
+}
+
+@available(macOS 26.0, *)
+private func dragConfigurationOperationsSnapshot(from operations: T) throws -> DragConfigurationOperationsSnapshot {
+ let mirror = Mirror(reflecting: operations)
+
+ func readBool(_ primary: String, fallback: String? = nil) throws -> Bool {
+ if let value = mirror.descendant(primary) as? Bool {
+ return value
+ }
+ if let fallback, let value = mirror.descendant(fallback) as? Bool {
+ return value
+ }
+ throw DragConfigurationSnapshotError.missingBoolField(primary: primary, fallback: fallback)
+ }
+
+ return try DragConfigurationOperationsSnapshot(
+ allowCopy: readBool("allowCopy", fallback: "_allowCopy"),
+ allowMove: readBool("allowMove", fallback: "_allowMove"),
+ allowDelete: readBool("allowDelete", fallback: "_allowDelete"),
+ allowAlias: readBool("allowAlias", fallback: "_allowAlias")
+ )
+}
+
+
+final class BrowserLinkOpenSettingsTests: XCTestCase {
+ private var suiteName: String!
+ private var defaults: UserDefaults!
+
+ override func setUp() {
+ super.setUp()
+ suiteName = "BrowserLinkOpenSettingsTests.\(UUID().uuidString)"
+ defaults = UserDefaults(suiteName: suiteName)
+ defaults.removePersistentDomain(forName: suiteName)
+ }
+
+ override func tearDown() {
+ defaults.removePersistentDomain(forName: suiteName)
+ defaults = nil
+ suiteName = nil
+ super.tearDown()
+ }
+
+ func testTerminalLinksDefaultToCmuxBrowser() {
+ XCTAssertTrue(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser(defaults: defaults))
+ }
+
+ func testTerminalLinksPreferenceUsesStoredValue() {
+ defaults.set(false, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey)
+ XCTAssertFalse(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser(defaults: defaults))
+
+ defaults.set(true, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey)
+ XCTAssertTrue(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser(defaults: defaults))
+ }
+
+ func testSidebarPullRequestLinksDefaultToCmuxBrowser() {
+ XCTAssertTrue(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowser(defaults: defaults))
+ }
+
+ func testSidebarPullRequestLinksPreferenceUsesStoredValue() {
+ defaults.set(false, forKey: BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey)
+ XCTAssertFalse(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowser(defaults: defaults))
+
+ defaults.set(true, forKey: BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey)
+ XCTAssertTrue(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowser(defaults: defaults))
+ }
+
+ func testOpenCommandInterceptionDefaultsToCmuxBrowser() {
+ XCTAssertTrue(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults))
+ }
+
+ func testOpenCommandInterceptionUsesStoredValue() {
+ defaults.set(false, forKey: BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey)
+ XCTAssertFalse(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults))
+
+ defaults.set(true, forKey: BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey)
+ XCTAssertTrue(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults))
+ }
+
+ func testOpenCommandInterceptionFallsBackToLegacyLinkToggleWhenUnset() {
+ defaults.set(false, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey)
+ XCTAssertFalse(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults))
+
+ defaults.set(true, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey)
+ XCTAssertTrue(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults))
+ }
+
+ func testSettingsInitialOpenCommandInterceptionValueFallsBackToLegacyLinkToggleWhenUnset() {
+ defaults.set(false, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey)
+ XCTAssertFalse(BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue(defaults: defaults))
+
+ defaults.set(true, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey)
+ XCTAssertTrue(BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue(defaults: defaults))
+ }
+
+ func testExternalOpenPatternsDefaultToEmpty() {
+ XCTAssertTrue(BrowserLinkOpenSettings.externalOpenPatterns(defaults: defaults).isEmpty)
+ }
+
+ func testExternalOpenLiteralPatternMatchesCaseInsensitively() {
+ defaults.set("openai.com/account/usage", forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey)
+ XCTAssertTrue(
+ BrowserLinkOpenSettings.shouldOpenExternally(
+ "https://platform.OPENAI.com/account/usage",
+ defaults: defaults
+ )
+ )
+ }
+
+ func testExternalOpenRegexPatternMatchesCaseInsensitively() {
+ defaults.set(
+ "re:^https?://[^/]*\\.example\\.com/(billing|usage)",
+ forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey
+ )
+ XCTAssertTrue(
+ BrowserLinkOpenSettings.shouldOpenExternally(
+ "https://FOO.example.com/BILLING",
+ defaults: defaults
+ )
+ )
+ }
+
+ func testExternalOpenRegexPatternSupportsDigitCharacterClass() {
+ defaults.set(
+ "re:^https://example\\.com/usage/\\d+$",
+ forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey
+ )
+ XCTAssertTrue(
+ BrowserLinkOpenSettings.shouldOpenExternally(
+ "https://example.com/usage/42",
+ defaults: defaults
+ )
+ )
+ }
+
+ func testExternalOpenPatternsIgnoreInvalidRegexEntries() {
+ defaults.set("re:(\nexample.com", forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey)
+ XCTAssertTrue(
+ BrowserLinkOpenSettings.shouldOpenExternally(
+ "https://example.com/path",
+ defaults: defaults
+ )
+ )
+ }
+}
+
+
+final class BrowserNavigableURLResolutionTests: XCTestCase {
+ func testResolvesFileSchemeAsNavigableURL() throws {
+ let resolved = try XCTUnwrap(resolveBrowserNavigableURL("file:///tmp/cmux-local-test.html"))
+ XCTAssertTrue(resolved.isFileURL)
+ XCTAssertEqual(resolved.path, "/tmp/cmux-local-test.html")
+ }
+
+ func testRejectsNonWebNonFileScheme() {
+ XCTAssertNil(resolveBrowserNavigableURL("mailto:test@example.com"))
+ XCTAssertNil(resolveBrowserNavigableURL("ftp://example.com/file.html"))
+ }
+
+ func testRejectsHostOnlyFileURL() {
+ XCTAssertNil(resolveBrowserNavigableURL("file://example.html"))
+ }
+}
+
+
+final class BrowserReadAccessURLTests: XCTestCase {
+ func testUsesParentDirectoryForFileURL() throws {
+ let tempRoot = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
+ let dir = tempRoot.appendingPathComponent("BrowserReadAccessURLTests-\(UUID().uuidString)", isDirectory: true)
+ let file = dir.appendingPathComponent("sample.html")
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: dir) }
+ try "".write(to: file, atomically: true, encoding: .utf8)
+
+ let readAccessURL = try XCTUnwrap(browserReadAccessURL(forLocalFileURL: file))
+ XCTAssertEqual(readAccessURL.standardizedFileURL, dir.standardizedFileURL)
+ }
+
+ func testUsesDirectoryURLWhenTargetIsDirectory() throws {
+ let tempRoot = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
+ let dir = tempRoot.appendingPathComponent("BrowserReadAccessURLTests-\(UUID().uuidString)", isDirectory: true)
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: dir) }
+
+ let readAccessURL = try XCTUnwrap(browserReadAccessURL(forLocalFileURL: dir))
+ XCTAssertEqual(readAccessURL.standardizedFileURL, dir.standardizedFileURL)
+ }
+
+ func testUsesParentDirectoryWhenFileDoesNotExist() throws {
+ let missing = URL(fileURLWithPath: "/tmp/\(UUID().uuidString).html")
+ let readAccessURL = try XCTUnwrap(browserReadAccessURL(forLocalFileURL: missing))
+ XCTAssertEqual(readAccessURL.standardizedFileURL, missing.deletingLastPathComponent().standardizedFileURL)
+ }
+
+ func testReturnsNilForHostOnlyFileURL() throws {
+ let hostOnly = try XCTUnwrap(URL(string: "file://example.html"))
+ XCTAssertNil(browserReadAccessURL(forLocalFileURL: hostOnly))
+ }
+}
+
+
+final class BrowserExternalNavigationSchemeTests: XCTestCase {
+ func testCustomAppSchemesOpenExternally() throws {
+ let discord = try XCTUnwrap(URL(string: "discord://login/one-time?token=abc"))
+ let slack = try XCTUnwrap(URL(string: "slack://open"))
+ let zoom = try XCTUnwrap(URL(string: "zoommtg://zoom.us/join"))
+ let mailto = try XCTUnwrap(URL(string: "mailto:test@example.com"))
+
+ XCTAssertTrue(browserShouldOpenURLExternally(discord))
+ XCTAssertTrue(browserShouldOpenURLExternally(slack))
+ XCTAssertTrue(browserShouldOpenURLExternally(zoom))
+ XCTAssertTrue(browserShouldOpenURLExternally(mailto))
+ }
+
+ func testEmbeddedBrowserSchemesStayInWebView() throws {
+ let https = try XCTUnwrap(URL(string: "https://example.com"))
+ let http = try XCTUnwrap(URL(string: "http://example.com"))
+ let about = try XCTUnwrap(URL(string: "about:blank"))
+ let data = try XCTUnwrap(URL(string: "data:text/plain,hello"))
+ let file = try XCTUnwrap(URL(string: "file:///tmp/cmux-local-test.html"))
+ let blob = try XCTUnwrap(URL(string: "blob:https://example.com/550e8400-e29b-41d4-a716-446655440000"))
+ let javascript = try XCTUnwrap(URL(string: "javascript:void(0)"))
+ let webkitInternal = try XCTUnwrap(URL(string: "applewebdata://local/page"))
+
+ XCTAssertFalse(browserShouldOpenURLExternally(https))
+ XCTAssertFalse(browserShouldOpenURLExternally(http))
+ XCTAssertFalse(browserShouldOpenURLExternally(about))
+ XCTAssertFalse(browserShouldOpenURLExternally(data))
+ XCTAssertFalse(browserShouldOpenURLExternally(file))
+ XCTAssertFalse(browserShouldOpenURLExternally(blob))
+ XCTAssertFalse(browserShouldOpenURLExternally(javascript))
+ XCTAssertFalse(browserShouldOpenURLExternally(webkitInternal))
+ }
+}
+
+
+final class BrowserHostWhitelistTests: XCTestCase {
+ private var suiteName: String!
+ private var defaults: UserDefaults!
+
+ override func setUp() {
+ super.setUp()
+ suiteName = "BrowserHostWhitelistTests.\(UUID().uuidString)"
+ defaults = UserDefaults(suiteName: suiteName)
+ defaults.removePersistentDomain(forName: suiteName)
+ }
+
+ override func tearDown() {
+ defaults.removePersistentDomain(forName: suiteName)
+ defaults = nil
+ suiteName = nil
+ super.tearDown()
+ }
+
+ func testEmptyWhitelistAllowsAll() {
+ XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults))
+ XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults))
+ }
+
+ func testExactMatch() {
+ defaults.set("localhost\n127.0.0.1", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
+ XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults))
+ XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("127.0.0.1", defaults: defaults))
+ XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults))
+ }
+
+ func testExactMatchIsCaseInsensitive() {
+ defaults.set("LocalHost", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
+ XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults))
+ XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("LOCALHOST", defaults: defaults))
+ }
+
+ func testWildcardSuffix() {
+ defaults.set("*.localtest.me", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
+ XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("app.localtest.me", defaults: defaults))
+ XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("sub.app.localtest.me", defaults: defaults))
+ XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localtest.me", defaults: defaults))
+ XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults))
+ }
+
+ func testWildcardIsCaseInsensitive() {
+ defaults.set("*.Example.COM", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
+ XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("sub.example.com", defaults: defaults))
+ }
+
+ func testBlankLinesAndWhitespaceIgnored() {
+ defaults.set(" localhost \n\n 127.0.0.1 \n", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
+ XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults))
+ XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("127.0.0.1", defaults: defaults))
+ XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults))
+ }
+
+ func testMixedExactAndWildcard() {
+ defaults.set("localhost\n127.0.0.1\n*.local.dev", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
+ XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults))
+ XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("127.0.0.1", defaults: defaults))
+ XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("app.local.dev", defaults: defaults))
+ XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("github.com", defaults: defaults))
+ }
+
+ func testDefaultWhitelistIsEmpty() {
+ let patterns = BrowserLinkOpenSettings.hostWhitelist(defaults: defaults)
+ XCTAssertTrue(patterns.isEmpty)
+ }
+
+ func testWildcardRequiresDotBoundary() {
+ defaults.set("*.example.com", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
+ XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("badexample.com", defaults: defaults))
+ XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com.evil", defaults: defaults))
+ }
+
+ func testWhitelistNormalizesSchemesPortsAndTrailingDots() {
+ defaults.set("https://LOCALHOST:3000/path\n*.Example.COM:443", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
+ XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost.", defaults: defaults))
+ XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("api.example.com", defaults: defaults))
+ }
+
+ func testInvalidWhitelistEntriesDoNotImplicitlyAllowAll() {
+ defaults.set("http://\n*.\n", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
+ XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults))
+ }
+
+ func testUnicodeWhitelistEntryMatchesPunycodeHost() {
+ defaults.set("b\u{00FC}cher.example", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
+ XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("xn--bcher-kva.example", defaults: defaults))
+ }
+}
+
+
+final class BrowserOmnibarFocusPolicyTests: XCTestCase {
+ func testReacquiresFocusWhenOmnibarStillWantsFocusAndNextResponderIsNotAnotherTextField() {
+ XCTAssertTrue(
+ browserOmnibarShouldReacquireFocusAfterEndEditing(
+ desiredOmnibarFocus: true,
+ nextResponderIsOtherTextField: false
+ )
+ )
+ }
+
+ func testDoesNotReacquireFocusWhenAnotherTextFieldAlreadyTookFocus() {
+ XCTAssertFalse(
+ browserOmnibarShouldReacquireFocusAfterEndEditing(
+ desiredOmnibarFocus: true,
+ nextResponderIsOtherTextField: true
+ )
+ )
+ }
+
+ func testDoesNotReacquireFocusWhenOmnibarNoLongerWantsFocus() {
+ XCTAssertFalse(
+ browserOmnibarShouldReacquireFocusAfterEndEditing(
+ desiredOmnibarFocus: false,
+ nextResponderIsOtherTextField: false
+ )
+ )
+ }
+}
diff --git a/cmuxTests/BrowserPanelTests.swift b/cmuxTests/BrowserPanelTests.swift
new file mode 100644
index 00000000..104cecbf
--- /dev/null
+++ b/cmuxTests/BrowserPanelTests.swift
@@ -0,0 +1,2935 @@
+import XCTest
+import AppKit
+import SwiftUI
+import UniformTypeIdentifiers
+import WebKit
+import ObjectiveC.runtime
+import Bonsplit
+import UserNotifications
+
+#if canImport(cmux_DEV)
+@testable import cmux_DEV
+#elseif canImport(cmux)
+@testable import cmux
+#endif
+
+func drainMainQueue() {
+ let expectation = XCTestExpectation(description: "drain main queue")
+ DispatchQueue.main.async {
+ expectation.fulfill()
+ }
+ XCTWaiter().wait(for: [expectation], timeout: 1.0)
+}
+
+@MainActor
+func makeTemporaryBrowserProfile(named prefix: String) throws -> BrowserProfileDefinition {
+ try XCTUnwrap(
+ BrowserProfileStore.shared.createProfile(
+ named: "\(prefix)-\(UUID().uuidString)"
+ )
+ )
+}
+
+final class BrowserPanelChromeBackgroundColorTests: XCTestCase {
+ func testLightModeUsesThemeBackgroundColor() {
+ assertResolvedColorMatchesTheme(for: .light)
+ }
+
+ func testDarkModeUsesThemeBackgroundColor() {
+ assertResolvedColorMatchesTheme(for: .dark)
+ }
+
+ private func assertResolvedColorMatchesTheme(
+ for colorScheme: ColorScheme,
+ file: StaticString = #filePath,
+ line: UInt = #line
+ ) {
+ let themeBackground = NSColor(srgbRed: 0.13, green: 0.29, blue: 0.47, alpha: 1.0)
+
+ guard
+ let actual = resolvedBrowserChromeBackgroundColor(
+ for: colorScheme,
+ themeBackgroundColor: themeBackground
+ ).usingColorSpace(.sRGB),
+ let expected = themeBackground.usingColorSpace(.sRGB)
+ else {
+ XCTFail("Expected sRGB-convertible colors", file: file, line: line)
+ return
+ }
+
+ XCTAssertEqual(actual.redComponent, expected.redComponent, accuracy: 0.001, file: file, line: line)
+ XCTAssertEqual(actual.greenComponent, expected.greenComponent, accuracy: 0.001, file: file, line: line)
+ XCTAssertEqual(actual.blueComponent, expected.blueComponent, accuracy: 0.001, file: file, line: line)
+ XCTAssertEqual(actual.alphaComponent, expected.alphaComponent, accuracy: 0.001, file: file, line: line)
+ }
+}
+
+
+final class BrowserPanelOmnibarPillBackgroundColorTests: XCTestCase {
+ func testLightModeSlightlyDarkensThemeBackground() {
+ assertResolvedColorMatchesExpectedBlend(for: .light, darkenMix: 0.04)
+ }
+
+ func testDarkModeSlightlyDarkensThemeBackground() {
+ assertResolvedColorMatchesExpectedBlend(for: .dark, darkenMix: 0.05)
+ }
+
+ private func assertResolvedColorMatchesExpectedBlend(
+ for colorScheme: ColorScheme,
+ darkenMix: CGFloat,
+ file: StaticString = #filePath,
+ line: UInt = #line
+ ) {
+ let themeBackground = NSColor(srgbRed: 0.94, green: 0.93, blue: 0.91, alpha: 1.0)
+ let expected = themeBackground.blended(withFraction: darkenMix, of: .black) ?? themeBackground
+
+ guard
+ let actual = resolvedBrowserOmnibarPillBackgroundColor(
+ for: colorScheme,
+ themeBackgroundColor: themeBackground
+ ).usingColorSpace(.sRGB),
+ let expectedSRGB = expected.usingColorSpace(.sRGB),
+ let themeSRGB = themeBackground.usingColorSpace(.sRGB)
+ else {
+ XCTFail("Expected sRGB-convertible colors", file: file, line: line)
+ return
+ }
+
+ XCTAssertEqual(actual.redComponent, expectedSRGB.redComponent, accuracy: 0.001, file: file, line: line)
+ XCTAssertEqual(actual.greenComponent, expectedSRGB.greenComponent, accuracy: 0.001, file: file, line: line)
+ XCTAssertEqual(actual.blueComponent, expectedSRGB.blueComponent, accuracy: 0.001, file: file, line: line)
+ XCTAssertEqual(actual.alphaComponent, expectedSRGB.alphaComponent, accuracy: 0.001, file: file, line: line)
+ XCTAssertNotEqual(actual.redComponent, themeSRGB.redComponent, file: file, line: line)
+ }
+}
+
+
+@MainActor
+final class BrowserPanelProfileIsolationTests: XCTestCase {
+ func testStaleDidFinishDoesNotRecordVisitIntoSwitchedProfileHistory() throws {
+ let alternateProfile = try makeTemporaryBrowserProfile(named: "Switched")
+ let defaultStore = BrowserHistoryStore.shared
+ let alternateStore = BrowserProfileStore.shared.historyStore(for: alternateProfile.id)
+ defaultStore.clearHistory()
+ alternateStore.clearHistory()
+ defer {
+ defaultStore.clearHistory()
+ alternateStore.clearHistory()
+ }
+
+ let panel = BrowserPanel(
+ workspaceId: UUID(),
+ profileID: BrowserProfileStore.shared.builtInDefaultProfileID
+ )
+ let staleWebView = panel.webView
+ let staleDelegate = try XCTUnwrap(staleWebView.navigationDelegate)
+ let staleURL = try XCTUnwrap(URL(string: "https://example.com/stale-finish"))
+ staleWebView.loadHTMLString(
+ "Stalestale",
+ baseURL: staleURL
+ )
+
+ XCTAssertTrue(
+ panel.switchToProfile(alternateProfile.id),
+ "Expected profile switch to succeed, current=\(panel.profileID) requested=\(alternateProfile.id) exists=\(BrowserProfileStore.shared.profileDefinition(id: alternateProfile.id) != nil)"
+ )
+ defaultStore.clearHistory()
+ alternateStore.clearHistory()
+
+ staleDelegate.webView?(staleWebView, didFinish: nil)
+ drainMainQueue()
+
+ XCTAssertTrue(
+ defaultStore.entries.isEmpty,
+ "Expected stale completion callbacks to avoid writing into the old profile history store, found \(defaultStore.entries.map { $0.url })"
+ )
+ XCTAssertTrue(
+ alternateStore.entries.isEmpty,
+ "Expected stale completion callbacks to avoid writing into the newly selected profile history store, found \(alternateStore.entries.map { $0.url })"
+ )
+ }
+}
+
+
+@MainActor
+final class BrowserPanelAddressBarFocusRequestTests: XCTestCase {
+ func testRequestPersistsUntilAcknowledged() {
+ let panel = BrowserPanel(workspaceId: UUID())
+ XCTAssertNil(panel.pendingAddressBarFocusRequestId)
+
+ let requestId = panel.requestAddressBarFocus()
+ XCTAssertEqual(panel.pendingAddressBarFocusRequestId, requestId)
+ XCTAssertTrue(panel.shouldSuppressWebViewFocus())
+
+ panel.acknowledgeAddressBarFocusRequest(requestId)
+ XCTAssertNil(panel.pendingAddressBarFocusRequestId)
+
+ // Acknowledgement only clears the durable request; focus suppression follows
+ // explicit blur state transitions.
+ XCTAssertTrue(panel.shouldSuppressWebViewFocus())
+ panel.endSuppressWebViewFocusForAddressBar()
+ XCTAssertFalse(panel.shouldSuppressWebViewFocus())
+ }
+
+ func testRequestCoalescesWhilePending() {
+ let panel = BrowserPanel(workspaceId: UUID())
+ let firstRequest = panel.requestAddressBarFocus()
+ let secondRequest = panel.requestAddressBarFocus()
+
+ XCTAssertEqual(firstRequest, secondRequest)
+ XCTAssertEqual(panel.pendingAddressBarFocusRequestId, firstRequest)
+ }
+
+ func testStaleAcknowledgementDoesNotClearNewestRequest() {
+ let panel = BrowserPanel(workspaceId: UUID())
+ let firstRequest = panel.requestAddressBarFocus()
+ panel.acknowledgeAddressBarFocusRequest(firstRequest)
+ let secondRequest = panel.requestAddressBarFocus()
+
+ XCTAssertNotEqual(firstRequest, secondRequest)
+ XCTAssertEqual(panel.pendingAddressBarFocusRequestId, secondRequest)
+
+ panel.acknowledgeAddressBarFocusRequest(firstRequest)
+ XCTAssertEqual(panel.pendingAddressBarFocusRequestId, secondRequest)
+
+ panel.acknowledgeAddressBarFocusRequest(secondRequest)
+ XCTAssertNil(panel.pendingAddressBarFocusRequestId)
+ }
+}
+
+
+@MainActor
+final class WindowBrowserHostViewTests: XCTestCase {
+ private final class CapturingView: NSView {
+ override func hitTest(_ point: NSPoint) -> NSView? {
+ bounds.contains(point) ? self : nil
+ }
+ }
+
+ private final class PrimaryPageProbeView: NSView {
+ override func hitTest(_ point: NSPoint) -> NSView? {
+ bounds.contains(point) ? self : nil
+ }
+ }
+
+ private final class WKInspectorProbeView: NSView {
+ override func hitTest(_ point: NSPoint) -> NSView? {
+ bounds.contains(point) ? self : nil
+ }
+ }
+
+ private final class EdgeTransparentWKInspectorProbeView: NSView {
+ override func hitTest(_ point: NSPoint) -> NSView? {
+ let localPoint = convert(point, from: superview)
+ guard bounds.contains(localPoint) else { return nil }
+ return localPoint.x <= 12 ? nil : self
+ }
+ }
+
+ private final class TrailingEdgeTransparentWKInspectorProbeView: NSView {
+ override func hitTest(_ point: NSPoint) -> NSView? {
+ let localPoint = convert(point, from: superview)
+ guard bounds.contains(localPoint) else { return nil }
+ return localPoint.x >= bounds.maxX - 12 ? nil : self
+ }
+ }
+
+ private final class BonsplitMockSplitDelegate: NSObject, NSSplitViewDelegate {}
+
+ private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent {
+ guard let event = NSEvent.mouseEvent(
+ with: type,
+ location: location,
+ modifierFlags: [],
+ timestamp: ProcessInfo.processInfo.systemUptime,
+ windowNumber: window.windowNumber,
+ context: nil,
+ eventNumber: 0,
+ clickCount: 1,
+ pressure: 1.0
+ ) else {
+ fatalError("Failed to create \(type) mouse event")
+ }
+ return event
+ }
+
+ private func isInspectorOwnedHit(_ hit: NSView?, inspectorView: NSView, pageView: NSView) -> Bool {
+ guard let hit else { return false }
+ if hit === pageView || hit.isDescendant(of: pageView) {
+ return false
+ }
+ if hit === inspectorView || hit.isDescendant(of: inspectorView) {
+ return true
+ }
+ return inspectorView.isDescendant(of: hit) && !(pageView === hit || pageView.isDescendant(of: hit))
+ }
+
+ func testHostViewPassesThroughDividerWhenAdjacentPaneIsCollapsed() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 300, height: 180),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let splitView = NSSplitView(frame: contentView.bounds)
+ splitView.autoresizingMask = [.width, .height]
+ splitView.isVertical = true
+ splitView.dividerStyle = .thin
+ let splitDelegate = BonsplitMockSplitDelegate()
+ splitView.delegate = splitDelegate
+ let first = NSView(frame: NSRect(x: 0, y: 0, width: 120, height: contentView.bounds.height))
+ let second = NSView(frame: NSRect(x: 121, y: 0, width: 179, height: contentView.bounds.height))
+ splitView.addSubview(first)
+ splitView.addSubview(second)
+ contentView.addSubview(splitView)
+ splitView.setPosition(1, ofDividerAt: 0)
+ splitView.adjustSubviews()
+ contentView.layoutSubtreeIfNeeded()
+
+ guard let container = contentView.superview else {
+ XCTFail("Expected content container")
+ return
+ }
+
+ let hostFrame = container.convert(contentView.bounds, from: contentView)
+ let host = WindowBrowserHostView(frame: hostFrame)
+ host.autoresizingMask = [.width, .height]
+ let child = CapturingView(frame: host.bounds)
+ child.autoresizingMask = [.width, .height]
+ host.addSubview(child)
+ container.addSubview(host, positioned: .above, relativeTo: contentView)
+
+ let dividerPointInSplit = NSPoint(
+ x: splitView.arrangedSubviews[0].frame.maxX + (splitView.dividerThickness * 0.5),
+ y: splitView.bounds.midY
+ )
+ let dividerPointInWindow = splitView.convert(dividerPointInSplit, to: nil)
+ let dividerPointInHost = host.convert(dividerPointInWindow, from: nil)
+ XCTAssertLessThanOrEqual(splitView.arrangedSubviews[0].frame.width, 1.5)
+ XCTAssertNil(
+ host.hitTest(dividerPointInHost),
+ "Browser host must pass through divider hits even when one pane is nearly collapsed"
+ )
+
+ let contentPointInSplit = NSPoint(x: dividerPointInSplit.x + 40, y: splitView.bounds.midY)
+ let contentPointInWindow = splitView.convert(contentPointInSplit, to: nil)
+ let contentPointInHost = host.convert(contentPointInWindow, from: nil)
+ XCTAssertTrue(host.hitTest(contentPointInHost) === child)
+ }
+
+ func testWindowBrowserPortalIgnoresHostedInspectorSplitResizeNotifications() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+ guard let container = contentView.superview else {
+ XCTFail("Expected content container")
+ return
+ }
+
+ let hostFrame = container.convert(contentView.bounds, from: contentView)
+ let host = WindowBrowserHostView(frame: hostFrame)
+ host.autoresizingMask = [.width, .height]
+ container.addSubview(host, positioned: .above, relativeTo: contentView)
+
+ let appSplit = NSSplitView(frame: contentView.bounds)
+ appSplit.autoresizingMask = [.width, .height]
+ appSplit.isVertical = true
+ appSplit.addSubview(NSView(frame: NSRect(x: 0, y: 0, width: 120, height: contentView.bounds.height)))
+ appSplit.addSubview(NSView(frame: NSRect(x: 121, y: 0, width: 299, height: contentView.bounds.height)))
+ contentView.addSubview(appSplit)
+
+ let inspectorSplit = NSSplitView(frame: host.bounds)
+ inspectorSplit.autoresizingMask = [.width, .height]
+ inspectorSplit.isVertical = true
+ inspectorSplit.addSubview(NSView(frame: NSRect(x: 0, y: 0, width: 120, height: host.bounds.height)))
+ inspectorSplit.addSubview(NSView(frame: NSRect(x: 121, y: 0, width: 299, height: host.bounds.height)))
+ host.addSubview(inspectorSplit)
+
+ XCTAssertTrue(
+ WindowBrowserPortal.shouldTreatSplitResizeAsExternalGeometry(
+ appSplit,
+ window: window,
+ hostView: host
+ ),
+ "App layout splits should still trigger browser portal geometry sync"
+ )
+ XCTAssertFalse(
+ WindowBrowserPortal.shouldTreatSplitResizeAsExternalGeometry(
+ inspectorSplit,
+ window: window,
+ hostView: host
+ ),
+ "Hosted DevTools/internal splits should not trigger browser portal geometry sync"
+ )
+ }
+
+ func testDragHoverEventsPassThroughForTabTransferOnBrowserHoverEvents() {
+ XCTAssertTrue(
+ WindowBrowserHostView.shouldPassThroughToDragTargets(
+ pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType],
+ eventType: .cursorUpdate
+ )
+ )
+ XCTAssertTrue(
+ WindowBrowserHostView.shouldPassThroughToDragTargets(
+ pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType],
+ eventType: .mouseEntered
+ )
+ )
+ }
+
+ func testDragHoverEventsPassThroughForSidebarReorderWithoutMouseButtonState() {
+ XCTAssertTrue(
+ WindowBrowserHostView.shouldPassThroughToDragTargets(
+ pasteboardTypes: [DragOverlayRoutingPolicy.sidebarTabReorderType],
+ eventType: .cursorUpdate
+ )
+ )
+ }
+
+ func testDragHoverEventsDoNotPassThroughForUnrelatedPasteboardTypes() {
+ XCTAssertFalse(
+ WindowBrowserHostView.shouldPassThroughToDragTargets(
+ pasteboardTypes: [.fileURL],
+ eventType: .cursorUpdate
+ )
+ )
+ }
+
+ func testHostViewKeepsHostedInspectorDividerInteractive() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+ guard let container = contentView.superview else {
+ XCTFail("Expected content container")
+ return
+ }
+
+ // Underlying app layout split that should still be pass-through.
+ let appSplit = NSSplitView(frame: contentView.bounds)
+ appSplit.autoresizingMask = [.width, .height]
+ appSplit.isVertical = true
+ appSplit.dividerStyle = .thin
+ let appSplitDelegate = BonsplitMockSplitDelegate()
+ appSplit.delegate = appSplitDelegate
+ let leading = NSView(frame: NSRect(x: 0, y: 0, width: 210, height: contentView.bounds.height))
+ let trailing = NSView(frame: NSRect(x: 211, y: 0, width: 209, height: contentView.bounds.height))
+ appSplit.addSubview(leading)
+ appSplit.addSubview(trailing)
+ contentView.addSubview(appSplit)
+ appSplit.adjustSubviews()
+
+ let hostFrame = container.convert(contentView.bounds, from: contentView)
+ let host = WindowBrowserHostView(frame: hostFrame)
+ host.autoresizingMask = [.width, .height]
+ container.addSubview(host, positioned: .above, relativeTo: contentView)
+
+ // WebKit inspector uses an internal split (page + console). Divider drags
+ // here must stay in hosted content, not pass through to appSplit behind it.
+ let inspectorSplit = NSSplitView(frame: host.bounds)
+ inspectorSplit.autoresizingMask = [.width, .height]
+ inspectorSplit.isVertical = false
+ inspectorSplit.dividerStyle = .thin
+ let inspectorDelegate = BonsplitMockSplitDelegate()
+ inspectorSplit.delegate = inspectorDelegate
+ let pageView = CapturingView(frame: NSRect(x: 0, y: 0, width: host.bounds.width, height: 160))
+ let consoleView = CapturingView(frame: NSRect(x: 0, y: 161, width: host.bounds.width, height: 99))
+ inspectorSplit.addSubview(pageView)
+ inspectorSplit.addSubview(consoleView)
+ host.addSubview(inspectorSplit)
+ inspectorSplit.setPosition(160, ofDividerAt: 0)
+ inspectorSplit.adjustSubviews()
+ contentView.layoutSubtreeIfNeeded()
+
+ let appDividerPointInSplit = NSPoint(
+ x: appSplit.arrangedSubviews[0].frame.maxX + (appSplit.dividerThickness * 0.5),
+ y: appSplit.bounds.midY
+ )
+ let appDividerPointInWindow = appSplit.convert(appDividerPointInSplit, to: nil)
+ let appDividerPointInHost = host.convert(appDividerPointInWindow, from: nil)
+ XCTAssertNil(
+ host.hitTest(appDividerPointInHost),
+ "Underlying app split divider should still pass through with a hosted inspector split present"
+ )
+
+ let dividerPointInInspector = NSPoint(
+ x: inspectorSplit.bounds.midX,
+ y: inspectorSplit.arrangedSubviews[0].frame.maxY + (inspectorSplit.dividerThickness * 0.5)
+ )
+ let dividerPointInWindow = inspectorSplit.convert(dividerPointInInspector, to: nil)
+ let dividerPointInHost = host.convert(dividerPointInWindow, from: nil)
+ let hit = host.hitTest(dividerPointInHost)
+
+ XCTAssertNotNil(
+ hit,
+ "Inspector divider should receive hit-testing in hosted content, not pass through"
+ )
+ XCTAssertFalse(hit === host)
+ if let hit {
+ XCTAssertTrue(
+ hit === inspectorSplit || hit.isDescendant(of: inspectorSplit),
+ "Expected hit to remain inside inspector split subtree"
+ )
+ }
+ }
+
+ func testHostViewKeepsHostedVerticalInspectorDividerInteractiveAtSlotLeadingEdge() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+ guard let container = contentView.superview else {
+ XCTFail("Expected content container")
+ return
+ }
+
+ let hostFrame = container.convert(contentView.bounds, from: contentView)
+ let host = WindowBrowserHostView(frame: hostFrame)
+ host.autoresizingMask = [.width, .height]
+ container.addSubview(host, positioned: .above, relativeTo: contentView)
+
+ let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height))
+ slot.autoresizingMask = [.minXMargin, .height]
+ host.addSubview(slot)
+
+ let inspectorSplit = NSSplitView(frame: slot.bounds)
+ inspectorSplit.autoresizingMask = [.width, .height]
+ inspectorSplit.isVertical = true
+ inspectorSplit.dividerStyle = .thin
+ let inspectorDelegate = BonsplitMockSplitDelegate()
+ inspectorSplit.delegate = inspectorDelegate
+ let pageView = CapturingView(frame: NSRect(x: 0, y: 0, width: 1, height: slot.bounds.height))
+ let inspectorView = CapturingView(
+ frame: NSRect(x: 2, y: 0, width: slot.bounds.width - 2, height: slot.bounds.height)
+ )
+ inspectorSplit.addSubview(pageView)
+ inspectorSplit.addSubview(inspectorView)
+ slot.addSubview(inspectorSplit)
+ inspectorSplit.setPosition(1, ofDividerAt: 0)
+ inspectorSplit.adjustSubviews()
+ contentView.layoutSubtreeIfNeeded()
+
+ let dividerPointInSplit = NSPoint(
+ x: inspectorSplit.arrangedSubviews[0].frame.maxX + (inspectorSplit.dividerThickness * 0.5),
+ y: inspectorSplit.bounds.midY
+ )
+ let dividerPointInWindow = inspectorSplit.convert(dividerPointInSplit, to: nil)
+ let dividerPointInHost = host.convert(dividerPointInWindow, from: nil)
+
+ XCTAssertLessThanOrEqual(inspectorSplit.arrangedSubviews[0].frame.width, 1.5)
+ XCTAssertTrue(
+ abs(dividerPointInHost.x - slot.frame.minX) <= SidebarResizeInteraction.hitWidthPerSide,
+ "Expected collapsed hosted divider to overlap the browser slot leading-edge resizer zone"
+ )
+
+ let hit = host.hitTest(dividerPointInHost)
+ XCTAssertNotNil(
+ hit,
+ "Hosted vertical inspector divider should stay interactive even when collapsed onto the slot edge"
+ )
+ XCTAssertFalse(hit === host)
+ if let hit {
+ XCTAssertTrue(
+ hit === inspectorSplit || hit.isDescendant(of: inspectorSplit),
+ "Expected hit to remain inside hosted inspector split subtree at the slot edge"
+ )
+ }
+ }
+
+ func testHostViewPrefersNativeHostedInspectorSiblingDividerHit() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+ guard let container = contentView.superview else {
+ XCTFail("Expected content container")
+ return
+ }
+
+ let hostFrame = container.convert(contentView.bounds, from: contentView)
+ let host = WindowBrowserHostView(frame: hostFrame)
+ host.autoresizingMask = [.width, .height]
+ container.addSubview(host, positioned: .above, relativeTo: contentView)
+
+ let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height))
+ slot.autoresizingMask = [.minXMargin, .height]
+ host.addSubview(slot)
+
+ let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: slot.bounds.height))
+ let inspectorView = WKInspectorProbeView(
+ frame: NSRect(x: 92, y: 0, width: slot.bounds.width - 92, height: slot.bounds.height)
+ )
+ slot.addSubview(pageView)
+ slot.addSubview(inspectorView)
+ contentView.layoutSubtreeIfNeeded()
+
+ let dividerPointInSlot = NSPoint(x: inspectorView.frame.minX + 2, y: slot.bounds.midY)
+ let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil)
+ let dividerPointInHost = host.convert(dividerPointInWindow, from: nil)
+ let bodyPointInSlot = NSPoint(x: inspectorView.frame.minX + 18, y: slot.bounds.midY)
+ let bodyPointInWindow = slot.convert(bodyPointInSlot, to: nil)
+ let bodyPointInHost = host.convert(bodyPointInWindow, from: nil)
+
+ let dividerHit = host.hitTest(dividerPointInHost)
+ XCTAssertTrue(
+ isInspectorOwnedHit(dividerHit, inspectorView: inspectorView, pageView: pageView),
+ "Hosted right-docked inspector divider should stay on the native WebKit hit path when WebKit exposes a hittable inspector-side view. actual=\(String(describing: dividerHit))"
+ )
+ let interiorHit = host.hitTest(bodyPointInHost)
+ XCTAssertTrue(
+ isInspectorOwnedHit(interiorHit, inspectorView: inspectorView, pageView: pageView),
+ "Only the divider edge should be claimed; interior inspector hits should still reach WebKit content. actual=\(String(describing: interiorHit))"
+ )
+ }
+
+ func testHostViewPrefersNativeNestedHostedInspectorSiblingDividerHit() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+ guard let container = contentView.superview else {
+ XCTFail("Expected content container")
+ return
+ }
+
+ let hostFrame = container.convert(contentView.bounds, from: contentView)
+ let host = WindowBrowserHostView(frame: hostFrame)
+ host.autoresizingMask = [.width, .height]
+ container.addSubview(host, positioned: .above, relativeTo: contentView)
+
+ let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height))
+ slot.autoresizingMask = [.minXMargin, .height]
+ host.addSubview(slot)
+
+ let wrapper = NSView(frame: slot.bounds)
+ wrapper.autoresizingMask = [.width, .height]
+ slot.addSubview(wrapper)
+
+ let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: wrapper.bounds.height))
+ let inspectorContainer = NSView(
+ frame: NSRect(x: 92, y: 0, width: wrapper.bounds.width - 92, height: wrapper.bounds.height)
+ )
+ let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
+ inspectorView.autoresizingMask = [.width, .height]
+ inspectorContainer.addSubview(inspectorView)
+ wrapper.addSubview(pageView)
+ wrapper.addSubview(inspectorContainer)
+ contentView.layoutSubtreeIfNeeded()
+
+ let dividerPointInSlot = NSPoint(x: inspectorContainer.frame.minX + 2, y: slot.bounds.midY)
+ let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil)
+ let dividerPointInHost = host.convert(dividerPointInWindow, from: nil)
+ let bodyPointInSlot = NSPoint(x: inspectorContainer.frame.minX + 18, y: slot.bounds.midY)
+ let bodyPointInWindow = slot.convert(bodyPointInSlot, to: nil)
+ let bodyPointInHost = host.convert(bodyPointInWindow, from: nil)
+
+ let dividerHit = host.hitTest(dividerPointInHost)
+ XCTAssertTrue(
+ isInspectorOwnedHit(dividerHit, inspectorView: inspectorView, pageView: pageView),
+ "Portal host should prefer the native nested WebKit hit target on the right-docked divider when available. actual=\(String(describing: dividerHit))"
+ )
+ let interiorHit = host.hitTest(bodyPointInHost)
+ XCTAssertTrue(
+ isInspectorOwnedHit(interiorHit, inspectorView: inspectorView, pageView: pageView),
+ "Only the divider edge should be claimed; interior nested inspector hits should still reach WebKit content. actual=\(String(describing: interiorHit))"
+ )
+ }
+
+ func testHostViewReappliesStoredHostedInspectorWidthAfterSlotLayoutReset() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+ guard let container = contentView.superview else {
+ XCTFail("Expected content container")
+ return
+ }
+
+ let hostFrame = container.convert(contentView.bounds, from: contentView)
+ let host = WindowBrowserHostView(frame: hostFrame)
+ host.autoresizingMask = [.width, .height]
+ container.addSubview(host, positioned: .above, relativeTo: contentView)
+
+ let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height))
+ slot.autoresizingMask = [.minXMargin, .height]
+ host.addSubview(slot)
+
+ let wrapper = NSView(frame: slot.bounds)
+ wrapper.autoresizingMask = [.width, .height]
+ slot.addSubview(wrapper)
+
+ let originalPageFrame = NSRect(x: 0, y: 0, width: 92, height: wrapper.bounds.height)
+ let originalInspectorFrame = NSRect(
+ x: 92,
+ y: 0,
+ width: wrapper.bounds.width - 92,
+ height: wrapper.bounds.height
+ )
+ let pageView = PrimaryPageProbeView(frame: originalPageFrame)
+ let inspectorContainer = NSView(frame: originalInspectorFrame)
+ let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
+ inspectorView.autoresizingMask = [.width, .height]
+ inspectorContainer.addSubview(inspectorView)
+ wrapper.addSubview(pageView)
+ wrapper.addSubview(inspectorContainer)
+ contentView.layoutSubtreeIfNeeded()
+
+ let dividerPointInSlot = NSPoint(x: inspectorContainer.frame.minX, y: slot.bounds.midY)
+ let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil)
+
+ let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)
+ host.mouseDown(with: down)
+ let drag = makeMouseEvent(
+ type: .leftMouseDragged,
+ location: NSPoint(x: dividerPointInWindow.x + 48, y: dividerPointInWindow.y),
+ window: window
+ )
+ host.mouseDragged(with: drag)
+ host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
+
+ let draggedPageWidth = pageView.frame.width
+ let draggedInspectorMinX = inspectorContainer.frame.minX
+ XCTAssertGreaterThan(draggedPageWidth, originalPageFrame.width)
+ XCTAssertGreaterThan(draggedInspectorMinX, originalInspectorFrame.minX)
+
+ pageView.frame = originalPageFrame
+ inspectorContainer.frame = originalInspectorFrame
+ slot.needsLayout = true
+ slot.layoutSubtreeIfNeeded()
+ host.layoutSubtreeIfNeeded()
+
+ XCTAssertEqual(pageView.frame.width, draggedPageWidth, accuracy: 0.5)
+ XCTAssertEqual(inspectorContainer.frame.minX, draggedInspectorMinX, accuracy: 0.5)
+ }
+
+ func testHostViewFallsBackToManualHostedInspectorDragWhenNativeDividerHitIsUnavailable() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+ guard let container = contentView.superview else {
+ XCTFail("Expected content container")
+ return
+ }
+
+ let hostFrame = container.convert(contentView.bounds, from: contentView)
+ let host = WindowBrowserHostView(frame: hostFrame)
+ host.autoresizingMask = [.width, .height]
+ container.addSubview(host, positioned: .above, relativeTo: contentView)
+
+ let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height))
+ slot.autoresizingMask = [.minXMargin, .height]
+ host.addSubview(slot)
+
+ let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: slot.bounds.height))
+ let inspectorView = EdgeTransparentWKInspectorProbeView(
+ frame: NSRect(x: 92, y: 0, width: slot.bounds.width - 92, height: slot.bounds.height)
+ )
+ slot.addSubview(pageView)
+ slot.addSubview(inspectorView)
+ contentView.layoutSubtreeIfNeeded()
+
+ let dividerPointInSlot = NSPoint(x: inspectorView.frame.minX + 2, y: slot.bounds.midY)
+ let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil)
+ let dividerPointInHost = host.convert(dividerPointInWindow, from: nil)
+
+ let dividerHit = host.hitTest(dividerPointInHost)
+ XCTAssertTrue(
+ dividerHit === host,
+ "Host should only take the manual fallback path when the right-docked divider edge is not natively hittable. actual=\(String(describing: dividerHit))"
+ )
+
+ let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)
+ host.mouseDown(with: down)
+ let drag = makeMouseEvent(
+ type: .leftMouseDragged,
+ location: NSPoint(x: dividerPointInWindow.x + 40, y: dividerPointInWindow.y),
+ window: window
+ )
+ host.mouseDragged(with: drag)
+ host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
+
+ XCTAssertGreaterThan(pageView.frame.width, 92)
+ XCTAssertGreaterThan(inspectorView.frame.minX, 92)
+ }
+
+ func testHostViewFallsBackToManualHostedInspectorDragForLeftDockedInspector() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+ guard let container = contentView.superview else {
+ XCTFail("Expected content container")
+ return
+ }
+
+ let hostFrame = container.convert(contentView.bounds, from: contentView)
+ let host = WindowBrowserHostView(frame: hostFrame)
+ host.autoresizingMask = [.width, .height]
+ container.addSubview(host, positioned: .above, relativeTo: contentView)
+
+ let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height))
+ slot.autoresizingMask = [.minXMargin, .height]
+ host.addSubview(slot)
+
+ let inspectorView = TrailingEdgeTransparentWKInspectorProbeView(
+ frame: NSRect(x: 0, y: 0, width: 92, height: slot.bounds.height)
+ )
+ let pageView = PrimaryPageProbeView(
+ frame: NSRect(x: 92, y: 0, width: slot.bounds.width - 92, height: slot.bounds.height)
+ )
+ slot.addSubview(inspectorView)
+ slot.addSubview(pageView)
+ contentView.layoutSubtreeIfNeeded()
+
+ let dividerPointInSlot = NSPoint(x: inspectorView.frame.maxX - 2, y: slot.bounds.midY)
+ let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil)
+ let dividerPointInHost = host.convert(dividerPointInWindow, from: nil)
+
+ XCTAssertTrue(
+ host.hitTest(dividerPointInHost) === host,
+ "Host should take the manual fallback path for a left-docked divider when the native edge is not hittable"
+ )
+
+ let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)
+ host.mouseDown(with: down)
+ let drag = makeMouseEvent(
+ type: .leftMouseDragged,
+ location: NSPoint(x: dividerPointInWindow.x + 40, y: dividerPointInWindow.y),
+ window: window
+ )
+ host.mouseDragged(with: drag)
+ host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
+
+ XCTAssertGreaterThan(inspectorView.frame.width, 92)
+ XCTAssertGreaterThan(pageView.frame.minX, 92)
+ }
+
+ func testHostViewClaimsCollapsedHostedInspectorSiblingDividerAtSlotLeadingEdge() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+ guard let container = contentView.superview else {
+ XCTFail("Expected content container")
+ return
+ }
+
+ let hostFrame = container.convert(contentView.bounds, from: contentView)
+ let host = WindowBrowserHostView(frame: hostFrame)
+ host.autoresizingMask = [.width, .height]
+ container.addSubview(host, positioned: .above, relativeTo: contentView)
+
+ let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height))
+ slot.autoresizingMask = [.minXMargin, .height]
+ host.addSubview(slot)
+
+ let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 0, height: slot.bounds.height))
+ let inspectorView = WKInspectorProbeView(frame: slot.bounds)
+ slot.addSubview(pageView)
+ slot.addSubview(inspectorView)
+ contentView.layoutSubtreeIfNeeded()
+
+ let dividerPointInSlot = NSPoint(x: inspectorView.frame.minX + 2, y: slot.bounds.midY)
+ let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil)
+ let dividerPointInHost = host.convert(dividerPointInWindow, from: nil)
+
+ XCTAssertLessThanOrEqual(dividerPointInHost.x - slot.frame.minX, SidebarResizeInteraction.hitWidthPerSide)
+ let dividerHit = host.hitTest(dividerPointInHost)
+ XCTAssertTrue(
+ isInspectorOwnedHit(dividerHit, inspectorView: inspectorView, pageView: pageView),
+ "Collapsed right-docked hosted inspector divider should stay on the native WebKit hit path while still beating the sidebar-resizer overlap zone. actual=\(String(describing: dividerHit))"
+ )
+ }
+}
+
+
+@MainActor
+final class BrowserPanelHostContainerViewTests: XCTestCase {
+ private final class PrimaryPageProbeView: NSView {
+ override func hitTest(_ point: NSPoint) -> NSView? {
+ bounds.contains(point) ? self : nil
+ }
+ }
+
+ private final class TrackingInspectorFrontendWebView: WKWebView {
+ private(set) var evaluatedJavaScript: [String] = []
+
+ @MainActor override func evaluateJavaScript(
+ _ javaScriptString: String,
+ completionHandler: (@MainActor @Sendable (Any?, (any Error)?) -> Void)? = nil
+ ) {
+ evaluatedJavaScript.append(javaScriptString)
+ completionHandler?(nil, nil)
+ }
+ }
+
+ private final class WKInspectorProbeView: NSView {
+ override func hitTest(_ point: NSPoint) -> NSView? {
+ bounds.contains(point) ? self : nil
+ }
+ }
+
+ private final class EdgeTransparentWKInspectorProbeView: NSView {
+ override func hitTest(_ point: NSPoint) -> NSView? {
+ let localPoint = convert(point, from: superview)
+ guard bounds.contains(localPoint) else { return nil }
+ return localPoint.x <= 12 ? nil : self
+ }
+ }
+
+ private final class TrailingEdgeTransparentWKInspectorProbeView: NSView {
+ override func hitTest(_ point: NSPoint) -> NSView? {
+ let localPoint = convert(point, from: superview)
+ guard bounds.contains(localPoint) else { return nil }
+ return localPoint.x >= bounds.maxX - 12 ? nil : self
+ }
+ }
+
+ private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent {
+ guard let event = NSEvent.mouseEvent(
+ with: type,
+ location: location,
+ modifierFlags: [],
+ timestamp: ProcessInfo.processInfo.systemUptime,
+ windowNumber: window.windowNumber,
+ context: nil,
+ eventNumber: 0,
+ clickCount: 1,
+ pressure: 1.0
+ ) else {
+ fatalError("Failed to create \(type) mouse event")
+ }
+ return event
+ }
+
+ func testBrowserPanelHostPrefersNativeHostedInspectorSiblingDividerHit() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
+ host.autoresizingMask = [.minXMargin, .height]
+ contentView.addSubview(host)
+
+ let webViewRoot = NSView(frame: host.bounds)
+ webViewRoot.autoresizingMask = [.width, .height]
+ host.addSubview(webViewRoot)
+
+ let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height))
+ let inspectorContainer = NSView(
+ frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height)
+ )
+ let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
+ inspectorView.autoresizingMask = [.width, .height]
+ inspectorContainer.addSubview(inspectorView)
+ webViewRoot.addSubview(pageView)
+ webViewRoot.addSubview(inspectorContainer)
+ contentView.layoutSubtreeIfNeeded()
+
+ let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY)
+ let bodyPointInHost = NSPoint(x: inspectorContainer.frame.minX + 18, y: host.bounds.midY)
+ let interiorHit = host.hitTest(bodyPointInHost)
+
+ XCTAssertTrue(
+ host.hitTest(dividerPointInHost) === host,
+ "Browser panel host should claim the right-docked divider edge for the manual resize path"
+ )
+ XCTAssertTrue(
+ interiorHit == nil || interiorHit !== host,
+ "Only the divider edge should be claimed; interior inspector hits should not be stolen by the host. actual=\(String(describing: interiorHit))"
+ )
+ }
+
+ func testBrowserPanelHostClaimsCollapsedHostedInspectorSiblingDividerAtLeadingEdge() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
+ host.autoresizingMask = [.minXMargin, .height]
+ contentView.addSubview(host)
+
+ let webViewRoot = NSView(frame: host.bounds)
+ webViewRoot.autoresizingMask = [.width, .height]
+ host.addSubview(webViewRoot)
+
+ let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 0, height: webViewRoot.bounds.height))
+ let inspectorContainer = NSView(frame: webViewRoot.bounds)
+ let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
+ inspectorView.autoresizingMask = [.width, .height]
+ inspectorContainer.addSubview(inspectorView)
+ webViewRoot.addSubview(pageView)
+ webViewRoot.addSubview(inspectorContainer)
+ contentView.layoutSubtreeIfNeeded()
+
+ let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY)
+ let dividerPointInWindow = host.convert(dividerPointInHost, to: nil)
+
+ XCTAssertTrue(
+ host.hitTest(dividerPointInHost) === host,
+ "Collapsed right-docked divider should stay on the manual browser-panel resize path while beating the sidebar-resizer overlap"
+ )
+
+ let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)
+ host.mouseDown(with: down)
+ let drag = makeMouseEvent(
+ type: .leftMouseDragged,
+ location: NSPoint(x: dividerPointInWindow.x + 36, y: dividerPointInWindow.y),
+ window: window
+ )
+ host.mouseDragged(with: drag)
+ host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
+
+ XCTAssertGreaterThan(pageView.frame.width, 0)
+ XCTAssertGreaterThan(inspectorContainer.frame.minX, 0)
+ }
+
+ func testBrowserPanelHostClaimsHostedInspectorDividerAcrossFullHeight() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
+ host.autoresizingMask = [.minXMargin, .height]
+ contentView.addSubview(host)
+
+ let webViewRoot = NSView(frame: host.bounds)
+ webViewRoot.autoresizingMask = [.width, .height]
+ host.addSubview(webViewRoot)
+
+ let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 20, width: 92, height: webViewRoot.bounds.height - 40))
+ let inspectorContainer = EdgeTransparentWKInspectorProbeView(
+ frame: NSRect(x: 92, y: 20, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height - 40)
+ )
+ webViewRoot.addSubview(pageView)
+ webViewRoot.addSubview(inspectorContainer)
+ contentView.layoutSubtreeIfNeeded()
+
+ XCTAssertTrue(
+ host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: 4)) === host,
+ "The custom DevTools divider should remain draggable at the top edge of the browser pane"
+ )
+ XCTAssertTrue(
+ host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.maxY - 4)) === host,
+ "The custom DevTools divider should remain draggable at the bottom edge of the browser pane"
+ )
+ }
+
+ func testBrowserPanelHostFallsBackToManualHostedInspectorDragWhenNativeDividerHitIsUnavailable() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
+ host.autoresizingMask = [.minXMargin, .height]
+ contentView.addSubview(host)
+
+ let webViewRoot = NSView(frame: host.bounds)
+ webViewRoot.autoresizingMask = [.width, .height]
+ host.addSubview(webViewRoot)
+
+ let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height))
+ let inspectorContainer = EdgeTransparentWKInspectorProbeView(
+ frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height)
+ )
+ webViewRoot.addSubview(pageView)
+ webViewRoot.addSubview(inspectorContainer)
+ contentView.layoutSubtreeIfNeeded()
+
+ let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY)
+ let dividerPointInWindow = host.convert(dividerPointInHost, to: nil)
+
+ XCTAssertTrue(
+ host.hitTest(dividerPointInHost) === host,
+ "Browser panel host should only take the manual fallback path when the divider edge is not natively hittable"
+ )
+
+ let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)
+ host.mouseDown(with: down)
+ let drag = makeMouseEvent(
+ type: .leftMouseDragged,
+ location: NSPoint(x: dividerPointInWindow.x + 40, y: dividerPointInWindow.y),
+ window: window
+ )
+ host.mouseDragged(with: drag)
+ host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
+
+ XCTAssertGreaterThan(pageView.frame.width, 92)
+ XCTAssertGreaterThan(inspectorContainer.frame.minX, 92)
+ }
+
+ func testBrowserPanelHostKeepsInspectorResizableAfterShrinkingToMinimumWidth() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
+ host.autoresizingMask = [.minXMargin, .height]
+ contentView.addSubview(host)
+
+ let webViewRoot = NSView(frame: host.bounds)
+ webViewRoot.autoresizingMask = [.width, .height]
+ host.addSubview(webViewRoot)
+
+ let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height))
+ let inspectorContainer = EdgeTransparentWKInspectorProbeView(
+ frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height)
+ )
+ webViewRoot.addSubview(pageView)
+ webViewRoot.addSubview(inspectorContainer)
+ contentView.layoutSubtreeIfNeeded()
+
+ let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY)
+ let dividerPointInWindow = host.convert(dividerPointInHost, to: nil)
+
+ host.mouseDown(with: makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window))
+ let drag = makeMouseEvent(
+ type: .leftMouseDragged,
+ location: NSPoint(x: dividerPointInWindow.x + 220, y: dividerPointInWindow.y),
+ window: window
+ )
+ host.mouseDragged(with: drag)
+ host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
+
+ XCTAssertGreaterThanOrEqual(
+ inspectorContainer.frame.width,
+ 120,
+ "Shrinking the DevTools pane should clamp to a recoverable minimum width"
+ )
+ XCTAssertTrue(
+ host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: 4)) === host,
+ "After clamping, the DevTools divider should still be draggable near the top edge"
+ )
+ XCTAssertTrue(
+ host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.maxY - 4)) === host,
+ "After clamping, the DevTools divider should still be draggable near the bottom edge"
+ )
+ }
+
+ func testBrowserPanelHostPromotesVisibleRightDockedInspectorIntoManagedSideDock() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
+ host.autoresizingMask = [.minXMargin, .height]
+ contentView.addSubview(host)
+
+ let slotView = host.ensureLocalInlineSlotView()
+ let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 92, height: host.bounds.height + 180))
+ let inspectorView = WKWebView(
+ frame: NSRect(x: 92, y: 0, width: slotView.bounds.width - 92, height: host.bounds.height)
+ )
+ slotView.addSubview(pageView)
+ slotView.addSubview(inspectorView)
+ host.pinHostedWebView(pageView, in: slotView)
+ host.setHostedInspectorFrontendWebView(inspectorView)
+ contentView.layoutSubtreeIfNeeded()
+ host.layoutSubtreeIfNeeded()
+
+ XCTAssertTrue(
+ host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded(),
+ "A visible right-docked inspector should not wait on async dock-configuration JS before entering the managed side-dock path"
+ )
+ XCTAssertTrue(
+ pageView.superview === inspectorView.superview && pageView.superview !== slotView,
+ "Promotion should move both hosted inspector siblings into the managed side-dock container"
+ )
+ XCTAssertEqual(
+ pageView.frame.height,
+ host.bounds.height,
+ accuracy: 0.5,
+ "Promotion should normalize stale page heights to the host height so the page layer stops covering the divider"
+ )
+ XCTAssertEqual(
+ inspectorView.frame.height,
+ host.bounds.height,
+ accuracy: 0.5,
+ "Promotion should normalize the inspector height to the host height"
+ )
+ }
+
+ func testBrowserPanelHostAllowsRightDockedInspectorToExpandLeftAfterPromotion() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
+ host.autoresizingMask = [.minXMargin, .height]
+ contentView.addSubview(host)
+
+ let slotView = host.ensureLocalInlineSlotView()
+ let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 92, height: host.bounds.height))
+ let inspectorView = WKWebView(
+ frame: NSRect(x: 92, y: 0, width: slotView.bounds.width - 92, height: host.bounds.height)
+ )
+ slotView.addSubview(pageView)
+ slotView.addSubview(inspectorView)
+ host.pinHostedWebView(pageView, in: slotView)
+ host.setHostedInspectorFrontendWebView(inspectorView)
+ contentView.layoutSubtreeIfNeeded()
+ host.layoutSubtreeIfNeeded()
+
+ XCTAssertTrue(
+ host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded(),
+ "The managed side-dock path should be active before drag assertions run"
+ )
+
+ let initialPageWidth = pageView.frame.width
+ let initialInspectorWidth = inspectorView.frame.width
+ let dividerPointInHost = NSPoint(x: inspectorView.frame.minX + 2, y: host.bounds.midY)
+ let dividerPointInWindow = host.convert(dividerPointInHost, to: nil)
+
+ host.mouseDown(with: makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window))
+ let drag = makeMouseEvent(
+ type: .leftMouseDragged,
+ location: NSPoint(x: dividerPointInWindow.x - 40, y: dividerPointInWindow.y),
+ window: window
+ )
+ host.mouseDragged(with: drag)
+ host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
+
+ XCTAssertGreaterThan(
+ inspectorView.frame.width,
+ initialInspectorWidth,
+ "Right-docked DevTools should expand when the divider is dragged left"
+ )
+ XCTAssertLessThan(
+ pageView.frame.width,
+ initialPageWidth,
+ "Expanding right-docked DevTools should shrink the page width"
+ )
+ }
+
+ func testBrowserPanelHostKeepsAutomaticRightDockedWidthAboveMinimumWhileShrinking() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 140, y: 0, width: 280, height: contentView.bounds.height))
+ host.autoresizingMask = [.minXMargin, .height]
+ contentView.addSubview(host)
+
+ let slotView = host.ensureLocalInlineSlotView()
+ let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 132, height: host.bounds.height))
+ let inspectorView = WKWebView(
+ frame: NSRect(x: 132, y: 0, width: slotView.bounds.width - 132, height: host.bounds.height)
+ )
+ slotView.addSubview(pageView)
+ slotView.addSubview(inspectorView)
+ host.pinHostedWebView(pageView, in: slotView)
+ host.setHostedInspectorFrontendWebView(inspectorView)
+ contentView.layoutSubtreeIfNeeded()
+ host.layoutSubtreeIfNeeded()
+
+ XCTAssertTrue(host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded())
+
+ host.setPreferredHostedInspectorWidth(width: 80, widthFraction: nil)
+ host.setFrameSize(NSSize(width: 210, height: host.frame.height))
+ contentView.layoutSubtreeIfNeeded()
+ host.layoutSubtreeIfNeeded()
+
+ XCTAssertGreaterThanOrEqual(
+ inspectorView.frame.width,
+ 120,
+ "Automatic pane resize should honor the same minimum hosted inspector width as manual dragging"
+ )
+ XCTAssertEqual(
+ inspectorView.frame.height,
+ host.bounds.height,
+ accuracy: 0.5,
+ "Automatic shrink should keep the inspector vertically normalized to the host height"
+ )
+ }
+
+ func testBrowserPanelHostRequestsBottomDockWhenSideDockLeavesTooLittlePageWidth() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 280, height: contentView.bounds.height))
+ host.autoresizingMask = [.minXMargin, .height]
+ contentView.addSubview(host)
+
+ let slotView = host.ensureLocalInlineSlotView()
+ let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 120, height: host.bounds.height))
+ let inspectorView = TrackingInspectorFrontendWebView(
+ frame: NSRect(x: 120, y: 0, width: slotView.bounds.width - 120, height: host.bounds.height)
+ )
+ slotView.addSubview(pageView)
+ slotView.addSubview(inspectorView)
+ host.pinHostedWebView(pageView, in: slotView)
+ host.setHostedInspectorFrontendWebView(inspectorView)
+ contentView.layoutSubtreeIfNeeded()
+ host.layoutSubtreeIfNeeded()
+
+ XCTAssertTrue(host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded())
+
+ host.setFrameSize(NSSize(width: 210, height: host.frame.height))
+ contentView.layoutSubtreeIfNeeded()
+ host.layoutSubtreeIfNeeded()
+
+ XCTAssertTrue(
+ inspectorView.evaluatedJavaScript.contains(where: { $0.contains("WI._dockBottom()") }),
+ "Narrow pane widths should request bottom-docked DevTools instead of leaving the side-docked inspector in an unstable layout"
+ )
+ XCTAssertTrue(
+ inspectorView.evaluatedJavaScript.contains(where: { $0.contains("const allowSideDock = false;") }),
+ "Once a narrow pane proves it cannot safely side-dock DevTools, the inspector frontend should hide and disable left/right dock controls"
+ )
+ }
+
+ func testBrowserPanelManagedSideDockDoesNotAutoresizeDraggedFrames() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
+ host.autoresizingMask = [.minXMargin, .height]
+ contentView.addSubview(host)
+
+ let slotView = host.ensureLocalInlineSlotView()
+ let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 92, height: host.bounds.height))
+ let inspectorView = WKWebView(
+ frame: NSRect(x: 92, y: 0, width: slotView.bounds.width - 92, height: host.bounds.height)
+ )
+ slotView.addSubview(pageView)
+ slotView.addSubview(inspectorView)
+ host.pinHostedWebView(pageView, in: slotView)
+ host.setHostedInspectorFrontendWebView(inspectorView)
+ contentView.layoutSubtreeIfNeeded()
+ host.layoutSubtreeIfNeeded()
+
+ XCTAssertTrue(host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded())
+
+ let dividerPointInHost = NSPoint(x: inspectorView.frame.minX + 2, y: host.bounds.midY)
+ let dividerPointInWindow = host.convert(dividerPointInHost, to: nil)
+ host.mouseDown(with: makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window))
+ let drag = makeMouseEvent(
+ type: .leftMouseDragged,
+ location: NSPoint(x: dividerPointInWindow.x - 30, y: dividerPointInWindow.y),
+ window: window
+ )
+ host.mouseDragged(with: drag)
+ host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
+
+ guard let managedContainer = pageView.superview else {
+ XCTFail("Expected managed side-dock container")
+ return
+ }
+ let draggedPageFrame = pageView.frame
+ let draggedInspectorFrame = inspectorView.frame
+
+ managedContainer.setFrameSize(
+ NSSize(width: managedContainer.frame.width, height: managedContainer.frame.height + 24)
+ )
+
+ XCTAssertEqual(
+ pageView.frame.origin.x,
+ draggedPageFrame.origin.x,
+ accuracy: 0.5,
+ "Managed side-dock container should not autoresize the page back to a stale divider position"
+ )
+ XCTAssertEqual(
+ pageView.frame.width,
+ draggedPageFrame.width,
+ accuracy: 0.5,
+ "Managed side-dock container should preserve the dragged page width until the host explicitly reapplies layout"
+ )
+ XCTAssertEqual(
+ inspectorView.frame.origin.x,
+ draggedInspectorFrame.origin.x,
+ accuracy: 0.5,
+ "Managed side-dock container should preserve the dragged inspector origin"
+ )
+ XCTAssertEqual(
+ inspectorView.frame.width,
+ draggedInspectorFrame.width,
+ accuracy: 0.5,
+ "Managed side-dock container should preserve the dragged inspector width"
+ )
+ }
+
+ func testBrowserPanelHostFallsBackToManualHostedInspectorDragForLeftDockedInspector() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
+ host.autoresizingMask = [.minXMargin, .height]
+ contentView.addSubview(host)
+
+ let webViewRoot = NSView(frame: host.bounds)
+ webViewRoot.autoresizingMask = [.width, .height]
+ host.addSubview(webViewRoot)
+
+ let inspectorContainer = TrailingEdgeTransparentWKInspectorProbeView(
+ frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height)
+ )
+ let pageView = PrimaryPageProbeView(
+ frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height)
+ )
+ webViewRoot.addSubview(inspectorContainer)
+ webViewRoot.addSubview(pageView)
+ contentView.layoutSubtreeIfNeeded()
+
+ let dividerPointInHost = NSPoint(x: inspectorContainer.frame.maxX - 2, y: host.bounds.midY)
+ let dividerPointInWindow = host.convert(dividerPointInHost, to: nil)
+
+ XCTAssertTrue(
+ host.hitTest(dividerPointInHost) === host,
+ "Browser panel host should take the manual fallback path for a left-docked divider when the native edge is not hittable"
+ )
+
+ let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)
+ host.mouseDown(with: down)
+ let drag = makeMouseEvent(
+ type: .leftMouseDragged,
+ location: NSPoint(x: dividerPointInWindow.x + 40, y: dividerPointInWindow.y),
+ window: window
+ )
+ host.mouseDragged(with: drag)
+ host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
+
+ XCTAssertGreaterThan(inspectorContainer.frame.width, 92)
+ XCTAssertGreaterThan(pageView.frame.minX, 92)
+ }
+
+ func testBrowserPanelHostReappliesStoredHostedInspectorWidthAfterLayoutReset() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let host = WebViewRepresentable.HostContainerView(
+ frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)
+ )
+ host.autoresizingMask = [.minXMargin, .height]
+ contentView.addSubview(host)
+
+ let webViewRoot = NSView(frame: host.bounds)
+ webViewRoot.autoresizingMask = [.width, .height]
+ host.addSubview(webViewRoot)
+
+ let originalPageFrame = NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height)
+ let originalInspectorFrame = NSRect(
+ x: 92,
+ y: 0,
+ width: webViewRoot.bounds.width - 92,
+ height: webViewRoot.bounds.height
+ )
+ let pageView = PrimaryPageProbeView(frame: originalPageFrame)
+ let inspectorContainer = NSView(frame: originalInspectorFrame)
+ let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
+ inspectorView.autoresizingMask = [.width, .height]
+ inspectorContainer.addSubview(inspectorView)
+ webViewRoot.addSubview(pageView)
+ webViewRoot.addSubview(inspectorContainer)
+ contentView.layoutSubtreeIfNeeded()
+
+ let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY)
+ let dividerPointInWindow = host.convert(dividerPointInHost, to: nil)
+
+ let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)
+ host.mouseDown(with: down)
+ let drag = makeMouseEvent(
+ type: .leftMouseDragged,
+ location: NSPoint(x: dividerPointInWindow.x + 48, y: dividerPointInWindow.y),
+ window: window
+ )
+ host.mouseDragged(with: drag)
+ host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
+
+ let draggedPageWidth = pageView.frame.width
+ let draggedInspectorMinX = inspectorContainer.frame.minX
+ XCTAssertGreaterThan(draggedPageWidth, originalPageFrame.width)
+ XCTAssertGreaterThan(draggedInspectorMinX, originalInspectorFrame.minX)
+
+ pageView.frame = originalPageFrame
+ inspectorContainer.frame = originalInspectorFrame
+ host.needsLayout = true
+ host.layoutSubtreeIfNeeded()
+
+ XCTAssertEqual(pageView.frame.width, draggedPageWidth, accuracy: 0.5)
+ XCTAssertEqual(inspectorContainer.frame.minX, draggedInspectorMinX, accuracy: 0.5)
+ }
+
+ func testWindowBrowserSlotPinsHostedWebViewWithAutoresizingForAttachedInspector() {
+ let slot = WindowBrowserSlotView(frame: NSRect(x: 0, y: 0, width: 240, height: 180))
+ let webView = WKWebView(frame: .zero)
+ slot.addSubview(webView)
+
+ slot.pinHostedWebView(webView)
+ slot.frame = NSRect(x: 0, y: 0, width: 300, height: 220)
+ slot.layoutSubtreeIfNeeded()
+
+ XCTAssertTrue(webView.translatesAutoresizingMaskIntoConstraints)
+ XCTAssertEqual(webView.autoresizingMask, [.width, .height])
+ XCTAssertEqual(webView.frame, slot.bounds)
+ }
+
+ func testWindowBrowserSlotReattachesPlainWebViewAtFullBoundsAfterHiddenHostResize() {
+ let slot = WindowBrowserSlotView(frame: NSRect(x: 0, y: 0, width: 400, height: 180))
+ let webView = WKWebView(frame: .zero)
+ slot.addSubview(webView)
+ slot.pinHostedWebView(webView)
+ XCTAssertEqual(webView.frame, slot.bounds)
+
+ let externalHost = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 180))
+ webView.removeFromSuperview()
+ externalHost.addSubview(webView)
+ webView.frame = externalHost.bounds
+ webView.translatesAutoresizingMaskIntoConstraints = true
+ webView.autoresizingMask = [.width, .height]
+
+ slot.addSubview(webView)
+ slot.pinHostedWebView(webView)
+
+ slot.frame = NSRect(x: 0, y: 0, width: 300, height: 180)
+ slot.layoutSubtreeIfNeeded()
+
+ XCTAssertEqual(
+ webView.frame,
+ slot.bounds,
+ "Reattaching a plain web view should restore full-bounds hosting instead of preserving a stale inset frame from a hidden host"
+ )
+ }
+}
+
+
+@MainActor
+final class BrowserPaneDropRoutingTests: XCTestCase {
+ func testVerticalZonesFollowAppKitCoordinates() {
+ let size = CGSize(width: 240, height: 180)
+
+ XCTAssertEqual(
+ BrowserPaneDropRouting.zone(for: CGPoint(x: size.width * 0.5, y: size.height - 8), in: size),
+ .top
+ )
+ XCTAssertEqual(
+ BrowserPaneDropRouting.zone(for: CGPoint(x: size.width * 0.5, y: 8), in: size),
+ .bottom
+ )
+ }
+
+ func testTopChromeHeightPushesTopSplitThresholdIntoWebView() {
+ let size = CGSize(width: 240, height: 180)
+
+ XCTAssertEqual(
+ BrowserPaneDropRouting.zone(
+ for: CGPoint(x: size.width * 0.5, y: 110),
+ in: size,
+ topChromeHeight: 36
+ ),
+ .center
+ )
+ XCTAssertEqual(
+ BrowserPaneDropRouting.zone(
+ for: CGPoint(x: size.width * 0.5, y: 150),
+ in: size,
+ topChromeHeight: 36
+ ),
+ .top
+ )
+ }
+
+ func testHitTestingCapturesOnlyForRelevantDragEvents() {
+ XCTAssertTrue(
+ BrowserPaneDropTargetView.shouldCaptureHitTesting(
+ pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType],
+ eventType: .cursorUpdate
+ )
+ )
+ XCTAssertFalse(
+ BrowserPaneDropTargetView.shouldCaptureHitTesting(
+ pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType],
+ eventType: .leftMouseDown
+ )
+ )
+ XCTAssertFalse(
+ BrowserPaneDropTargetView.shouldCaptureHitTesting(
+ pasteboardTypes: [.fileURL],
+ eventType: .cursorUpdate
+ )
+ )
+ }
+
+ func testCenterDropOnSamePaneIsNoOp() {
+ let paneId = PaneID(id: UUID())
+ let target = BrowserPaneDropContext(
+ workspaceId: UUID(),
+ panelId: UUID(),
+ paneId: paneId
+ )
+ let transfer = BrowserPaneDragTransfer(
+ tabId: UUID(),
+ sourcePaneId: paneId.id,
+ sourceProcessId: Int32(ProcessInfo.processInfo.processIdentifier)
+ )
+
+ XCTAssertEqual(
+ BrowserPaneDropRouting.action(for: transfer, target: target, zone: .center),
+ .noOp
+ )
+ }
+
+ func testRightEdgeDropBuildsSplitMoveAction() {
+ let paneId = PaneID(id: UUID())
+ let target = BrowserPaneDropContext(
+ workspaceId: UUID(),
+ panelId: UUID(),
+ paneId: paneId
+ )
+ let tabId = UUID()
+ let transfer = BrowserPaneDragTransfer(
+ tabId: tabId,
+ sourcePaneId: UUID(),
+ sourceProcessId: Int32(ProcessInfo.processInfo.processIdentifier)
+ )
+
+ XCTAssertEqual(
+ BrowserPaneDropRouting.action(for: transfer, target: target, zone: .right),
+ .move(
+ tabId: tabId,
+ targetWorkspaceId: target.workspaceId,
+ targetPane: paneId,
+ splitTarget: BrowserPaneSplitTarget(orientation: .horizontal, insertFirst: false)
+ )
+ )
+ }
+
+ func testDecodeTransferPayloadReadsTabAndSourcePane() {
+ let tabId = UUID()
+ let sourcePaneId = UUID()
+ let payload = try! JSONSerialization.data(
+ withJSONObject: [
+ "tab": ["id": tabId.uuidString],
+ "sourcePaneId": sourcePaneId.uuidString,
+ "sourceProcessId": ProcessInfo.processInfo.processIdentifier,
+ ]
+ )
+
+ let transfer = BrowserPaneDragTransfer.decode(from: payload)
+
+ XCTAssertEqual(transfer?.tabId, tabId)
+ XCTAssertEqual(transfer?.sourcePaneId, sourcePaneId)
+ XCTAssertTrue(transfer?.isFromCurrentProcess == true)
+ }
+}
+
+
+@MainActor
+final class WindowBrowserSlotViewTests: XCTestCase {
+ private final class CapturingView: NSView {
+ override func hitTest(_ point: NSPoint) -> NSView? {
+ bounds.contains(point) ? self : nil
+ }
+ }
+
+ private func advanceAnimations() {
+ RunLoop.current.run(until: Date().addingTimeInterval(0.25))
+ }
+
+ func testDropZoneOverlayStaysAboveContentWithoutBlockingHits() {
+ let container = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 100))
+ let slot = WindowBrowserSlotView(frame: container.bounds)
+ container.addSubview(slot)
+ let child = CapturingView(frame: slot.bounds)
+ child.autoresizingMask = [.width, .height]
+ slot.addSubview(child)
+
+ slot.setDropZoneOverlay(zone: .right)
+ container.layoutSubtreeIfNeeded()
+
+ guard let overlay = container.subviews.first(where: {
+ $0 !== slot && String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView")
+ }) else {
+ XCTFail("Expected browser slot drop-zone overlay")
+ return
+ }
+
+ XCTAssertTrue(container.subviews.last === overlay, "Overlay should stay above the hosted web view")
+ XCTAssertFalse(overlay.isHidden)
+ XCTAssertEqual(overlay.frame.origin.x, 100, accuracy: 0.5)
+ XCTAssertEqual(overlay.frame.origin.y, 4, accuracy: 0.5)
+ XCTAssertEqual(overlay.frame.size.width, 96, accuracy: 0.5)
+ XCTAssertEqual(overlay.frame.size.height, 92, accuracy: 0.5)
+ XCTAssertNil(overlay.hitTest(NSPoint(x: 120, y: 50)), "Overlay should never intercept pointer hits")
+ XCTAssertTrue(slot.hitTest(NSPoint(x: 120, y: 50)) === child)
+
+ slot.setDropZoneOverlay(zone: nil)
+ advanceAnimations()
+ XCTAssertTrue(overlay.isHidden, "Clearing the drop zone should hide the overlay")
+ }
+
+ func testTopDropZoneOverlayUsesFullBrowserContentHeight() {
+ let container = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 100))
+ let slot = WindowBrowserSlotView(frame: container.bounds)
+ container.addSubview(slot)
+
+ slot.setPaneTopChromeHeight(20)
+ slot.setDropZoneOverlay(zone: .top)
+ container.layoutSubtreeIfNeeded()
+
+ guard let overlay = container.subviews.first(where: {
+ String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView")
+ }) else {
+ XCTFail("Expected browser slot drop-zone overlay")
+ return
+ }
+
+ XCTAssertFalse(overlay.isHidden)
+ XCTAssertEqual(overlay.frame.origin.x, 4, accuracy: 0.5)
+ XCTAssertEqual(overlay.frame.origin.y, 60, accuracy: 0.5)
+ XCTAssertEqual(overlay.frame.size.width, 192, accuracy: 0.5)
+ XCTAssertEqual(overlay.frame.size.height, 56, accuracy: 0.5)
+ XCTAssertGreaterThan(overlay.frame.maxY, slot.frame.maxY)
+ XCTAssertEqual(slot.layer?.masksToBounds, true)
+
+ slot.setDropZoneOverlay(zone: nil)
+ advanceAnimations()
+ XCTAssertEqual(slot.layer?.masksToBounds, true)
+ }
+}
+
+
+@MainActor
+final class BrowserWindowPortalLifecycleTests: XCTestCase {
+ private final class TrackingPortalWebView: WKWebView {
+ private(set) var displayIfNeededCount = 0
+ private(set) var reattachRenderingStateCount = 0
+
+ override func displayIfNeeded() {
+ displayIfNeededCount += 1
+ super.displayIfNeeded()
+ }
+
+ @objc(_enterInWindow)
+ func cmuxUnitTestEnterInWindow() {
+ reattachRenderingStateCount += 1
+ }
+
+ @objc(_endDeferringViewInWindowChangesSync)
+ func cmuxUnitTestEndDeferringViewInWindowChangesSync() {
+ reattachRenderingStateCount += 1
+ }
+ }
+
+ private final class WKInspectorProbeView: NSView {}
+
+ private func realizeWindowLayout(_ window: NSWindow) {
+ window.makeKeyAndOrderFront(nil)
+ window.displayIfNeeded()
+ window.contentView?.layoutSubtreeIfNeeded()
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+ window.contentView?.layoutSubtreeIfNeeded()
+ }
+
+ private func advanceAnimations() {
+ RunLoop.current.run(until: Date().addingTimeInterval(0.25))
+ }
+
+ private func dropZoneOverlay(in slot: WindowBrowserSlotView, excluding webView: WKWebView) -> NSView? {
+ let candidates = slot.subviews + (slot.superview?.subviews ?? [])
+ return candidates.first(where: {
+ $0 !== slot &&
+ $0 !== webView &&
+ String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView")
+ })
+ }
+
+ func testPortalHostInstallsAboveContentViewForVisibility() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ let portal = WindowBrowserPortal(window: window)
+ _ = portal.webViewAtWindowPoint(NSPoint(x: 1, y: 1))
+
+ guard let contentView = window.contentView,
+ let container = contentView.superview else {
+ XCTFail("Expected content container")
+ return
+ }
+
+ guard let hostIndex = container.subviews.firstIndex(where: { $0 is WindowBrowserHostView }),
+ let contentIndex = container.subviews.firstIndex(where: { $0 === contentView }) else {
+ XCTFail("Expected host/content views in same container")
+ return
+ }
+
+ XCTAssertGreaterThan(
+ hostIndex,
+ contentIndex,
+ "Browser portal host must remain above content view so portal-hosted web views stay visible"
+ )
+ }
+
+ func testBrowserPortalHostStaysAboveTerminalPortalHostDuringPortalChurn() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ realizeWindowLayout(window)
+
+ let browserPortal = WindowBrowserPortal(window: window)
+ let terminalPortal = WindowTerminalPortal(window: window)
+ _ = browserPortal.webViewAtWindowPoint(NSPoint(x: 1, y: 1))
+ _ = terminalPortal.viewAtWindowPoint(NSPoint(x: 1, y: 1))
+
+ guard let contentView = window.contentView,
+ let container = contentView.superview else {
+ XCTFail("Expected content container")
+ return
+ }
+
+ func assertHostOrder(_ message: String) {
+ guard let browserHostIndex = container.subviews.firstIndex(where: { $0 is WindowBrowserHostView }),
+ let terminalHostIndex = container.subviews.firstIndex(where: { $0 is WindowTerminalHostView }) else {
+ XCTFail("Expected both portal hosts in same container")
+ return
+ }
+
+ XCTAssertGreaterThan(
+ browserHostIndex,
+ terminalHostIndex,
+ message
+ )
+ }
+
+ assertHostOrder("Browser portal host should start above terminal portal host")
+
+ let terminalAnchor = NSView(frame: NSRect(x: 20, y: 20, width: 200, height: 140))
+ contentView.addSubview(terminalAnchor)
+ let terminalHostedView = GhosttySurfaceScrollView(
+ surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80))
+ )
+ terminalPortal.bind(hostedView: terminalHostedView, to: terminalAnchor, visibleInUI: true)
+ terminalPortal.synchronizeHostedViewForAnchor(terminalAnchor)
+ assertHostOrder("Terminal portal sync should not rise above the browser portal host")
+
+ let browserAnchor = NSView(frame: NSRect(x: 240, y: 20, width: 220, height: 140))
+ contentView.addSubview(browserAnchor)
+ let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
+ browserPortal.bind(webView: webView, to: browserAnchor, visibleInUI: true)
+ browserPortal.synchronizeWebViewForAnchor(browserAnchor)
+ assertHostOrder("Browser portal sync should keep browser panes above portal-hosted terminals")
+ }
+
+ func testAnchorRebindKeepsWebViewInStablePortalSuperview() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ realizeWindowLayout(window)
+ let portal = WindowBrowserPortal(window: window)
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let anchor1 = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 120))
+ let anchor2 = NSView(frame: NSRect(x: 240, y: 40, width: 180, height: 120))
+ contentView.addSubview(anchor1)
+ contentView.addSubview(anchor2)
+
+ let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
+ portal.bind(webView: webView, to: anchor1, visibleInUI: true)
+ let firstSuperview = webView.superview
+
+ XCTAssertNotNil(firstSuperview)
+ XCTAssertTrue(firstSuperview is WindowBrowserSlotView)
+
+ portal.bind(webView: webView, to: anchor2, visibleInUI: true)
+ XCTAssertTrue(webView.superview === firstSuperview, "Anchor moves should not reparent the web view")
+
+ contentView.layoutSubtreeIfNeeded()
+ portal.synchronizeWebViewForAnchor(anchor2)
+ guard let slot = webView.superview as? WindowBrowserSlotView,
+ let host = slot.superview as? WindowBrowserHostView else {
+ XCTFail("Expected browser slot + host views")
+ return
+ }
+ let expectedFrame = host.convert(anchor2.bounds, from: anchor2)
+ XCTAssertEqual(slot.frame.origin.x, expectedFrame.origin.x, accuracy: 0.5)
+ XCTAssertEqual(slot.frame.origin.y, expectedFrame.origin.y, accuracy: 0.5)
+ XCTAssertEqual(slot.frame.size.width, expectedFrame.size.width, accuracy: 0.5)
+ XCTAssertEqual(slot.frame.size.height, expectedFrame.size.height, accuracy: 0.5)
+ }
+
+ func testPortalClampsWebViewFrameToHostBoundsWhenAnchorOverflowsSidebar() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ realizeWindowLayout(window)
+ let portal = WindowBrowserPortal(window: window)
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ // Simulate a transient oversized anchor rect during split churn.
+ let anchor = NSView(frame: NSRect(x: 120, y: 20, width: 260, height: 150))
+ contentView.addSubview(anchor)
+
+ let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
+ portal.bind(webView: webView, to: anchor, visibleInUI: true)
+ contentView.layoutSubtreeIfNeeded()
+ portal.synchronizeWebViewForAnchor(anchor)
+
+ guard let slot = webView.superview as? WindowBrowserSlotView else {
+ XCTFail("Expected web view slot")
+ return
+ }
+
+ XCTAssertFalse(slot.isHidden, "Partially visible browser anchor should stay visible")
+ XCTAssertEqual(slot.frame.origin.x, 120, accuracy: 0.5)
+ XCTAssertEqual(slot.frame.origin.y, 20, accuracy: 0.5)
+ XCTAssertEqual(slot.frame.size.width, 200, accuracy: 0.5)
+ XCTAssertEqual(slot.frame.size.height, 150, accuracy: 0.5)
+ }
+
+ func testPortalClipsAnchorFrameThroughAncestorBounds() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 480, height: 320),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ realizeWindowLayout(window)
+ let portal = WindowBrowserPortal(window: window)
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let clipView = NSView(frame: NSRect(x: 60, y: 40, width: 150, height: 120))
+ contentView.addSubview(clipView)
+
+ // Simulate SwiftUI/AppKit reporting an anchor wider than the actual visible pane.
+ let anchor = NSView(frame: NSRect(x: -30, y: 0, width: 220, height: 120))
+ clipView.addSubview(anchor)
+
+ let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
+ portal.bind(webView: webView, to: anchor, visibleInUI: true)
+ contentView.layoutSubtreeIfNeeded()
+ clipView.layoutSubtreeIfNeeded()
+ portal.synchronizeWebViewForAnchor(anchor)
+
+ guard let slot = webView.superview as? WindowBrowserSlotView else {
+ XCTFail("Expected browser slot")
+ return
+ }
+
+ XCTAssertFalse(slot.isHidden, "Ancestor clipping should keep the browser visible in the real pane")
+ XCTAssertEqual(slot.frame.origin.x, 60, accuracy: 0.5)
+ XCTAssertEqual(slot.frame.origin.y, 40, accuracy: 0.5)
+ XCTAssertEqual(slot.frame.size.width, 150, accuracy: 0.5)
+ XCTAssertEqual(slot.frame.size.height, 120, accuracy: 0.5)
+ }
+
+ func testPortalSyncNormalizesOutOfBoundsWebFrame() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ realizeWindowLayout(window)
+ let portal = WindowBrowserPortal(window: window)
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let anchor = NSView(frame: NSRect(x: 40, y: 20, width: 220, height: 160))
+ contentView.addSubview(anchor)
+
+ let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
+ portal.bind(webView: webView, to: anchor, visibleInUI: true)
+ contentView.layoutSubtreeIfNeeded()
+ portal.synchronizeWebViewForAnchor(anchor)
+
+ guard let slot = webView.superview as? WindowBrowserSlotView else {
+ XCTFail("Expected browser slot")
+ return
+ }
+
+ // Reproduce observed drift from logs where WebKit shifts/expands frame beyond slot bounds.
+ webView.frame = NSRect(x: 0, y: 250, width: slot.bounds.width, height: slot.bounds.height)
+ XCTAssertGreaterThan(webView.frame.maxY, slot.bounds.maxY)
+
+ portal.synchronizeWebViewForAnchor(anchor)
+ XCTAssertEqual(webView.frame.origin.x, slot.bounds.origin.x, accuracy: 0.5)
+ XCTAssertEqual(webView.frame.origin.y, slot.bounds.origin.y, accuracy: 0.5)
+ XCTAssertEqual(webView.frame.size.width, slot.bounds.size.width, accuracy: 0.5)
+ XCTAssertEqual(webView.frame.size.height, slot.bounds.size.height, accuracy: 0.5)
+ }
+
+ func testPortalSlotPinPreservesSideDockedInspectorManagedWebViewFrameOnRehost() {
+ let slot = WindowBrowserSlotView(frame: NSRect(x: 0, y: 0, width: 240, height: 160))
+ let webView = CmuxWebView(frame: NSRect(x: 0, y: 0, width: 132, height: 160), configuration: WKWebViewConfiguration())
+ let inspectorContainer = NSView(frame: NSRect(x: 132, y: 0, width: 108, height: 160))
+ let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
+ inspectorView.autoresizingMask = [.width, .height]
+ inspectorContainer.addSubview(inspectorView)
+ slot.addSubview(webView)
+ slot.addSubview(inspectorContainer)
+
+ webView.translatesAutoresizingMaskIntoConstraints = false
+ webView.autoresizingMask = []
+ slot.pinHostedWebView(webView)
+
+ XCTAssertEqual(
+ webView.frame.maxX,
+ inspectorContainer.frame.minX,
+ accuracy: 0.5,
+ "Rehosting a portal-managed browser should preserve the WebKit-owned side inspector split"
+ )
+ XCTAssertLessThan(
+ webView.frame.width,
+ slot.bounds.width,
+ "The page frame should stay narrower than the full slot while a side-docked inspector is present"
+ )
+ }
+
+ func testPortalResizePreservesSideDockedInspectorManagedWebViewFrame() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 520, height: 320),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ realizeWindowLayout(window)
+ let portal = WindowBrowserPortal(window: window)
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 260, height: 180))
+ contentView.addSubview(anchor)
+
+ let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
+ portal.bind(webView: webView, to: anchor, visibleInUI: true)
+ contentView.layoutSubtreeIfNeeded()
+ portal.synchronizeWebViewForAnchor(anchor)
+
+ guard let slot = webView.superview as? WindowBrowserSlotView else {
+ XCTFail("Expected browser slot")
+ return
+ }
+
+ let initialInspectorWidth: CGFloat = 110
+ let inspectorContainer = NSView(
+ frame: NSRect(
+ x: slot.bounds.width - initialInspectorWidth,
+ y: 0,
+ width: initialInspectorWidth,
+ height: slot.bounds.height
+ )
+ )
+ inspectorContainer.autoresizingMask = [.minXMargin, .height]
+ let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
+ inspectorView.autoresizingMask = [.width, .height]
+ inspectorContainer.addSubview(inspectorView)
+ slot.addSubview(inspectorContainer)
+
+ webView.frame = NSRect(
+ x: 0,
+ y: 0,
+ width: slot.bounds.width - initialInspectorWidth,
+ height: slot.bounds.height
+ )
+ webView.autoresizingMask = [.width, .height]
+ slot.layoutSubtreeIfNeeded()
+
+ anchor.frame = NSRect(x: 40, y: 24, width: 220, height: 180)
+ contentView.layoutSubtreeIfNeeded()
+ portal.synchronizeWebViewForAnchor(anchor)
+
+ XCTAssertFalse(slot.isHidden, "Resizing the browser pane should keep the hosted browser visible")
+ XCTAssertEqual(
+ webView.frame.maxX,
+ inspectorContainer.frame.minX,
+ accuracy: 0.5,
+ "Portal sync should preserve the side-docked inspector split instead of stretching the page back over the inspector"
+ )
+ XCTAssertLessThan(
+ webView.frame.width,
+ slot.bounds.width,
+ "Side-docked inspector should still own part of the slot after pane resize"
+ )
+ }
+
+ func testPortalAnchorResizeDoesNotForceHostedWebViewPresentationRefresh() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 520, height: 320),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ realizeWindowLayout(window)
+ let portal = WindowBrowserPortal(window: window)
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 220, height: 160))
+ contentView.addSubview(anchor)
+
+ let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration())
+ portal.bind(webView: webView, to: anchor, visibleInUI: true)
+ contentView.layoutSubtreeIfNeeded()
+ portal.synchronizeWebViewForAnchor(anchor)
+ advanceAnimations()
+
+ guard let slot = webView.superview as? WindowBrowserSlotView else {
+ XCTFail("Expected browser slot")
+ return
+ }
+
+ let initialDisplayCount = webView.displayIfNeededCount
+ let initialReattachCount = webView.reattachRenderingStateCount
+ anchor.frame = NSRect(x: 52, y: 30, width: 248, height: 178)
+ contentView.layoutSubtreeIfNeeded()
+ portal.synchronizeWebViewForAnchor(anchor)
+ advanceAnimations()
+
+ XCTAssertFalse(slot.isHidden, "Anchor resize should keep the portal-hosted browser visible")
+ XCTAssertEqual(slot.frame.origin.x, 52, accuracy: 0.5)
+ XCTAssertEqual(slot.frame.origin.y, 30, accuracy: 0.5)
+ XCTAssertEqual(slot.frame.size.width, 248, accuracy: 0.5)
+ XCTAssertEqual(slot.frame.size.height, 178, accuracy: 0.5)
+ XCTAssertGreaterThan(
+ webView.displayIfNeededCount,
+ initialDisplayCount,
+ "Pure anchor geometry updates should still repaint the hosted browser"
+ )
+ XCTAssertEqual(
+ webView.reattachRenderingStateCount,
+ initialReattachCount,
+ "Pure anchor geometry updates should not trigger the WebKit reattach path"
+ )
+ }
+
+ func testExternalSplitResizeDoesNotForceHostedWebViewPresentationRefresh() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 640, height: 360),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ realizeWindowLayout(window)
+ let portal = WindowBrowserPortal(window: window)
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let splitView = NSSplitView(frame: contentView.bounds)
+ splitView.autoresizingMask = [.width, .height]
+ splitView.isVertical = true
+
+ let leadingPane = NSView(
+ frame: NSRect(x: 0, y: 0, width: 220, height: contentView.bounds.height)
+ )
+ leadingPane.autoresizingMask = [.height]
+ let trailingPane = NSView(
+ frame: NSRect(
+ x: 221,
+ y: 0,
+ width: contentView.bounds.width - 221,
+ height: contentView.bounds.height
+ )
+ )
+ trailingPane.autoresizingMask = [.width, .height]
+ splitView.addSubview(leadingPane)
+ splitView.addSubview(trailingPane)
+ contentView.addSubview(splitView)
+ splitView.adjustSubviews()
+
+ let anchor = NSView(frame: trailingPane.bounds.insetBy(dx: 12, dy: 12))
+ anchor.autoresizingMask = [.width, .height]
+ trailingPane.addSubview(anchor)
+
+ let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration())
+ portal.bind(webView: webView, to: anchor, visibleInUI: true)
+ contentView.layoutSubtreeIfNeeded()
+ portal.synchronizeWebViewForAnchor(anchor)
+ advanceAnimations()
+
+ guard let slot = webView.superview as? WindowBrowserSlotView else {
+ XCTFail("Expected browser slot")
+ return
+ }
+
+ let initialDisplayCount = webView.displayIfNeededCount
+ let initialReattachCount = webView.reattachRenderingStateCount
+ let initialWidth = slot.frame.width
+
+ splitView.setPosition(280, ofDividerAt: 0)
+ contentView.layoutSubtreeIfNeeded()
+ NotificationCenter.default.post(name: NSSplitView.didResizeSubviewsNotification, object: splitView)
+ advanceAnimations()
+
+ XCTAssertFalse(slot.isHidden, "App split resize should keep the browser slot visible")
+ XCTAssertLessThan(
+ slot.frame.width,
+ initialWidth,
+ "Moving the app split divider should shrink the hosted browser slot"
+ )
+ XCTAssertGreaterThan(
+ webView.displayIfNeededCount,
+ initialDisplayCount,
+ "External split resize should still repaint the hosted browser"
+ )
+ XCTAssertEqual(
+ webView.reattachRenderingStateCount,
+ initialReattachCount,
+ "External split resize should not trigger the WebKit reattach path"
+ )
+ }
+
+ func testPortalSyncRepairsBottomDockedInspectorOverflowedPageFrame() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 520, height: 320),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ realizeWindowLayout(window)
+ let portal = WindowBrowserPortal(window: window)
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 260, height: 180))
+ contentView.addSubview(anchor)
+
+ let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
+ portal.bind(webView: webView, to: anchor, visibleInUI: true)
+ contentView.layoutSubtreeIfNeeded()
+ portal.synchronizeWebViewForAnchor(anchor)
+
+ guard let slot = webView.superview as? WindowBrowserSlotView else {
+ XCTFail("Expected browser slot")
+ return
+ }
+
+ let inspectorHeight: CGFloat = 84
+ let inspectorContainer = NSView(
+ frame: NSRect(x: 0, y: 0, width: slot.bounds.width, height: inspectorHeight)
+ )
+ inspectorContainer.autoresizingMask = [.width]
+ let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
+ inspectorView.autoresizingMask = [.width, .height]
+ inspectorContainer.addSubview(inspectorView)
+ slot.addSubview(inspectorContainer)
+
+ webView.frame = NSRect(
+ x: 0,
+ y: inspectorHeight,
+ width: slot.bounds.width,
+ height: slot.bounds.height
+ )
+ webView.autoresizingMask = [.width, .height]
+ slot.layoutSubtreeIfNeeded()
+
+ portal.synchronizeWebViewForAnchor(anchor)
+
+ XCTAssertFalse(slot.isHidden, "Portal sync should keep the hosted browser visible")
+ XCTAssertEqual(
+ webView.frame.minY,
+ inspectorHeight,
+ accuracy: 0.5,
+ "Portal sync should keep the page viewport below a bottom-docked inspector instead of shifting the page upward"
+ )
+ XCTAssertEqual(
+ webView.frame.height,
+ slot.bounds.height - inspectorHeight,
+ accuracy: 0.5,
+ "Portal sync should shrink the page viewport to the space above a bottom-docked inspector"
+ )
+ XCTAssertEqual(
+ webView.frame.maxY,
+ slot.bounds.maxY,
+ accuracy: 0.5,
+ "The repaired page viewport should stay flush with the top edge of the slot"
+ )
+ }
+
+ func testHidingBrowserSlotYieldsOwnedInspectorFirstResponder() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 520, height: 320),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ realizeWindowLayout(window)
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let slot = WindowBrowserSlotView(frame: NSRect(x: 40, y: 24, width: 260, height: 180))
+ contentView.addSubview(slot)
+
+ let inspectorContainer = NSView(frame: slot.bounds)
+ inspectorContainer.autoresizingMask = [.width, .height]
+ let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
+ inspectorView.autoresizingMask = [.width, .height]
+ inspectorContainer.addSubview(inspectorView)
+ slot.addSubview(inspectorContainer)
+ contentView.layoutSubtreeIfNeeded()
+
+ XCTAssertTrue(
+ window.makeFirstResponder(inspectorView),
+ "Precondition failed: inspector probe should become first responder"
+ )
+ XCTAssertTrue(window.firstResponder === inspectorView)
+
+ slot.isHidden = true
+
+ XCTAssertFalse(
+ window.firstResponder === inspectorView,
+ "Hiding a browser slot should yield any owned inspector responder before it goes off-screen"
+ )
+ if let firstResponderView = window.firstResponder as? NSView {
+ XCTAssertFalse(
+ firstResponderView === slot || firstResponderView.isDescendant(of: slot),
+ "Hiding a browser slot should not leave first responder inside the hidden slot"
+ )
+ }
+ }
+
+ func testHiddenPortalSyncDoesNotStealLocallyHostedDevToolsWebViewDuringResize() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 520, height: 320),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ realizeWindowLayout(window)
+ let portal = WindowBrowserPortal(window: window)
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 260, height: 180))
+ contentView.addSubview(anchor)
+
+ let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
+ portal.bind(webView: webView, to: anchor, visibleInUI: true)
+ contentView.layoutSubtreeIfNeeded()
+ portal.synchronizeWebViewForAnchor(anchor)
+ advanceAnimations()
+
+ guard let hiddenPortalSlot = webView.superview as? WindowBrowserSlotView else {
+ XCTFail("Expected browser slot")
+ return
+ }
+
+ portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0)
+ portal.synchronizeWebViewForAnchor(anchor)
+ advanceAnimations()
+ XCTAssertTrue(hiddenPortalSlot.isHidden, "Hidden portal entry should keep its slot hidden")
+
+ let localInlineSlot = WindowBrowserSlotView(frame: anchor.frame)
+ contentView.addSubview(localInlineSlot)
+
+ let inspectorView = WKInspectorProbeView(
+ frame: NSRect(x: 0, y: 0, width: localInlineSlot.bounds.width, height: 72)
+ )
+ inspectorView.autoresizingMask = [.width]
+ localInlineSlot.addSubview(inspectorView)
+
+ localInlineSlot.addSubview(webView)
+ webView.frame = NSRect(
+ x: 0,
+ y: inspectorView.frame.maxY,
+ width: localInlineSlot.bounds.width,
+ height: localInlineSlot.bounds.height - inspectorView.frame.height
+ )
+ localInlineSlot.layoutSubtreeIfNeeded()
+
+ anchor.frame = NSRect(x: 40, y: 24, width: 220, height: 180)
+ localInlineSlot.frame = anchor.frame
+ contentView.layoutSubtreeIfNeeded()
+ localInlineSlot.layoutSubtreeIfNeeded()
+ portal.synchronizeWebViewForAnchor(anchor)
+
+ XCTAssertTrue(
+ webView.superview === localInlineSlot,
+ "Hidden portal sync should not steal a DevTools-hosted web view back out of local inline hosting during pane resize"
+ )
+ XCTAssertTrue(
+ inspectorView.superview === localInlineSlot,
+ "Hidden portal sync should leave local DevTools companion views in the local inline host"
+ )
+ XCTAssertTrue(hiddenPortalSlot.isHidden, "The retiring hidden portal slot should stay hidden during local inline hosting")
+ }
+
+ func testPortalHostBoundsBecomeReadyAfterBindingInFrameDrivenHierarchy() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ realizeWindowLayout(window)
+ let portal = WindowBrowserPortal(window: window)
+
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+ let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 220, height: 160))
+ contentView.addSubview(anchor)
+
+ let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
+ portal.bind(webView: webView, to: anchor, visibleInUI: true)
+ portal.synchronizeWebViewForAnchor(anchor)
+
+ guard let slot = webView.superview as? WindowBrowserSlotView,
+ let host = slot.superview as? WindowBrowserHostView else {
+ XCTFail("Expected portal slot + host views")
+ return
+ }
+ XCTAssertGreaterThan(host.bounds.width, 1, "Portal host width should be ready for clipping/sync")
+ XCTAssertGreaterThan(host.bounds.height, 1, "Portal host height should be ready for clipping/sync")
+ }
+
+ func testPortalDropZoneOverlayPersistsAcrossVisibilityChanges() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ realizeWindowLayout(window)
+ let portal = WindowBrowserPortal(window: window)
+
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+ let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 220, height: 160))
+ contentView.addSubview(anchor)
+
+ let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
+ portal.bind(webView: webView, to: anchor, visibleInUI: true)
+ portal.synchronizeWebViewForAnchor(anchor)
+
+ guard let slot = webView.superview as? WindowBrowserSlotView,
+ let overlay = dropZoneOverlay(in: slot, excluding: webView) else {
+ XCTFail("Expected browser slot overlay")
+ return
+ }
+
+ XCTAssertTrue(overlay.isHidden, "Overlay should start hidden without an active drop zone")
+
+ portal.updateDropZoneOverlay(forWebViewId: ObjectIdentifier(webView), zone: .right)
+ slot.layoutSubtreeIfNeeded()
+ XCTAssertFalse(overlay.isHidden)
+ XCTAssertTrue(slot.superview?.subviews.last === overlay, "Overlay should remain above the hosted web view")
+ XCTAssertEqual(overlay.frame.origin.x, slot.frame.origin.x + 110, accuracy: 0.5)
+ XCTAssertEqual(overlay.frame.origin.y, slot.frame.origin.y + 4, accuracy: 0.5)
+ XCTAssertEqual(overlay.frame.size.width, 106, accuracy: 0.5)
+ XCTAssertEqual(overlay.frame.size.height, 152, accuracy: 0.5)
+
+ portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0)
+ portal.synchronizeWebViewForAnchor(anchor)
+ advanceAnimations()
+ XCTAssertTrue(overlay.isHidden, "Invisible browser entries should hide the overlay")
+
+ portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: true, zPriority: 0)
+ portal.synchronizeWebViewForAnchor(anchor)
+ XCTAssertFalse(overlay.isHidden, "Restoring visibility should restore the active drop-zone overlay")
+ }
+
+ func testPortalRevealRefreshesHostedWebViewWithoutFrameDelta() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ realizeWindowLayout(window)
+ let portal = WindowBrowserPortal(window: window)
+
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+ let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 220, height: 160))
+ contentView.addSubview(anchor)
+
+ let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration())
+ portal.bind(webView: webView, to: anchor, visibleInUI: true)
+ portal.synchronizeWebViewForAnchor(anchor)
+ advanceAnimations()
+ let initialDisplayCount = webView.displayIfNeededCount
+ let initialReattachCount = webView.reattachRenderingStateCount
+
+ portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0)
+ portal.synchronizeWebViewForAnchor(anchor)
+ advanceAnimations()
+ let hiddenDisplayCount = webView.displayIfNeededCount
+ let hiddenReattachCount = webView.reattachRenderingStateCount
+
+ portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: true, zPriority: 0)
+ portal.synchronizeWebViewForAnchor(anchor)
+ advanceAnimations()
+
+ XCTAssertGreaterThanOrEqual(hiddenDisplayCount, initialDisplayCount)
+ XCTAssertEqual(
+ hiddenReattachCount,
+ initialReattachCount,
+ "Hiding a portal-hosted browser should not itself trigger the WebKit reattach path"
+ )
+ XCTAssertGreaterThan(
+ webView.displayIfNeededCount,
+ hiddenDisplayCount,
+ "Revealing an existing portal-hosted browser should refresh WebKit presentation immediately"
+ )
+ XCTAssertGreaterThan(
+ webView.reattachRenderingStateCount,
+ hiddenReattachCount,
+ "Revealing an existing portal-hosted browser should trigger the WebKit reattach path"
+ )
+ }
+
+ func testVisiblePortalEntryHidesWithoutDetachingDuringTransientAnchorRemovalUntilRebind() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ realizeWindowLayout(window)
+ let portal = WindowBrowserPortal(window: window)
+
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let anchorFrame = NSRect(x: 40, y: 24, width: 220, height: 160)
+ let anchor1 = NSView(frame: anchorFrame)
+ contentView.addSubview(anchor1)
+
+ let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration())
+ portal.bind(webView: webView, to: anchor1, visibleInUI: true)
+ portal.synchronizeWebViewForAnchor(anchor1)
+ advanceAnimations()
+
+ guard let slot = webView.superview as? WindowBrowserSlotView else {
+ XCTFail("Expected browser slot")
+ return
+ }
+
+ anchor1.removeFromSuperview()
+ portal.synchronizeWebViewForAnchor(anchor1)
+ advanceAnimations()
+
+ XCTAssertTrue(webView.superview === slot, "Visible browser entries should not detach during transient anchor removal")
+ XCTAssertTrue(
+ slot.isHidden,
+ "Transient anchor churn should hide the stale browser slot instead of rendering in the wrong pane"
+ )
+ XCTAssertEqual(portal.debugEntryCount(), 1)
+
+ let displayCountBeforeRebind = webView.displayIfNeededCount
+ let anchor2 = NSView(frame: anchorFrame)
+ contentView.addSubview(anchor2)
+ portal.bind(webView: webView, to: anchor2, visibleInUI: true)
+ portal.synchronizeWebViewForAnchor(anchor2)
+ advanceAnimations()
+
+ XCTAssertTrue(webView.superview === slot, "Rebinding after transient anchor removal should reuse the existing portal slot")
+ XCTAssertFalse(slot.isHidden)
+ XCTAssertEqual(portal.debugEntryCount(), 1)
+ XCTAssertGreaterThan(
+ webView.displayIfNeededCount,
+ displayCountBeforeRebind,
+ "Anchor rebinds should refresh hosted browser presentation even when geometry is unchanged"
+ )
+ }
+
+ func testVisiblePortalEntryStaysVisibleDuringOffWindowAnchorReparentUntilRebind() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ realizeWindowLayout(window)
+ let portal = WindowBrowserPortal(window: window)
+
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let anchorFrame = NSRect(x: 40, y: 24, width: 220, height: 160)
+ let anchor = NSView(frame: anchorFrame)
+ contentView.addSubview(anchor)
+
+ let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration())
+ portal.bind(webView: webView, to: anchor, visibleInUI: true)
+ portal.synchronizeWebViewForAnchor(anchor)
+ advanceAnimations()
+
+ guard let slot = webView.superview as? WindowBrowserSlotView else {
+ XCTFail("Expected browser slot")
+ return
+ }
+
+ let offWindowContainer = NSView(frame: anchorFrame)
+ anchor.removeFromSuperview()
+ offWindowContainer.addSubview(anchor)
+ portal.synchronizeWebViewForAnchor(anchor)
+ advanceAnimations()
+
+ XCTAssertTrue(
+ webView.superview === slot,
+ "Off-window anchor reparent should preserve the hosted browser slot during drag churn"
+ )
+ XCTAssertFalse(
+ slot.isHidden,
+ "Off-window anchor reparent should keep the visible browser portal alive until the anchor returns"
+ )
+ XCTAssertEqual(portal.debugEntryCount(), 1)
+
+ contentView.addSubview(anchor)
+ portal.synchronizeWebViewForAnchor(anchor)
+ advanceAnimations()
+
+ XCTAssertTrue(webView.superview === slot, "Rebinding after off-window reparent should reuse the existing portal slot")
+ XCTAssertFalse(slot.isHidden)
+ XCTAssertEqual(portal.debugEntryCount(), 1)
+ }
+
+ func testRegistryDetachRemovesPortalHostedWebView() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ realizeWindowLayout(window)
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let anchor = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 120))
+ contentView.addSubview(anchor)
+ let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
+
+ BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true)
+ XCTAssertNotNil(webView.superview)
+
+ BrowserWindowPortalRegistry.detach(webView: webView)
+ XCTAssertNil(webView.superview)
+ }
+
+ func testRegistryHideKeepsPortalHostedWebViewAttachedButHidden() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ realizeWindowLayout(window)
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let anchor = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 120))
+ contentView.addSubview(anchor)
+ let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
+
+ BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true)
+ BrowserWindowPortalRegistry.synchronizeForAnchor(anchor)
+ advanceAnimations()
+
+ guard let slot = webView.superview as? WindowBrowserSlotView else {
+ XCTFail("Expected browser slot")
+ return
+ }
+ XCTAssertFalse(slot.isHidden)
+
+ BrowserWindowPortalRegistry.hide(webView: webView, source: "unitTest")
+ advanceAnimations()
+
+ XCTAssertTrue(webView.superview === slot, "Hiding should preserve the hosted WKWebView attachment")
+ XCTAssertTrue(slot.isHidden, "Hiding should immediately hide the existing portal slot")
+ }
+
+ func testHiddenPortalEntrySurvivesAnchorRemovalUntilWorkspaceRebind() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ realizeWindowLayout(window)
+ let portal = WindowBrowserPortal(window: window)
+
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let anchorFrame = NSRect(x: 40, y: 24, width: 220, height: 160)
+ let oldAnchor = NSView(frame: anchorFrame)
+ contentView.addSubview(oldAnchor)
+
+ let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration())
+ portal.bind(webView: webView, to: oldAnchor, visibleInUI: true)
+ portal.synchronizeWebViewForAnchor(oldAnchor)
+ advanceAnimations()
+
+ guard let slot = webView.superview as? WindowBrowserSlotView else {
+ XCTFail("Expected browser slot")
+ return
+ }
+
+ portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0)
+ portal.synchronizeWebViewForAnchor(oldAnchor)
+ advanceAnimations()
+ XCTAssertTrue(slot.isHidden, "Workspace handoff should hide the retiring browser before unmount")
+
+ oldAnchor.removeFromSuperview()
+ portal.synchronizeWebViewForAnchor(oldAnchor)
+ advanceAnimations()
+
+ XCTAssertTrue(
+ webView.superview === slot,
+ "Hidden workspace browsers should stay attached while their SwiftUI anchor is temporarily unmounted"
+ )
+ XCTAssertTrue(slot.isHidden, "Unmounted hidden workspace browser should remain hidden until rebound")
+ XCTAssertEqual(portal.debugEntryCount(), 1, "Workspace handoff should keep the hidden browser portal entry alive")
+
+ let displayCountBeforeRebind = webView.displayIfNeededCount
+ let newAnchor = NSView(frame: anchorFrame)
+ contentView.addSubview(newAnchor)
+ portal.bind(webView: webView, to: newAnchor, visibleInUI: true)
+ portal.synchronizeWebViewForAnchor(newAnchor)
+ advanceAnimations()
+
+ XCTAssertTrue(
+ webView.superview === slot,
+ "Selecting the workspace again should reuse the existing hidden browser portal slot"
+ )
+ XCTAssertFalse(slot.isHidden, "Rebinding the workspace browser should reveal the existing portal slot")
+ XCTAssertEqual(portal.debugEntryCount(), 1)
+ XCTAssertGreaterThan(
+ webView.displayIfNeededCount,
+ displayCountBeforeRebind,
+ "Workspace rebind should refresh the preserved browser without recreating its portal slot"
+ )
+ }
+}
diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
deleted file mode 100644
index 9f343720..00000000
--- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
+++ /dev/null
@@ -1,15907 +0,0 @@
-import XCTest
-import AppKit
-import SwiftUI
-import UniformTypeIdentifiers
-import WebKit
-import SwiftUI
-import ObjectiveC.runtime
-import Bonsplit
-import UserNotifications
-
-#if canImport(cmux_DEV)
-@testable import cmux_DEV
-#elseif canImport(cmux)
-@testable import cmux
-#endif
-
-private let lastSurfaceCloseShortcutDefaultsKey = "closeWorkspaceOnLastSurfaceShortcut"
-
-private var cmuxUnitTestInspectorAssociationKey: UInt8 = 0
-private var cmuxUnitTestInspectorOverrideInstalled = false
-
-private extension CmuxWebView {
- @objc func cmuxUnitTestInspector() -> NSObject? {
- objc_getAssociatedObject(self, &cmuxUnitTestInspectorAssociationKey) as? NSObject
- }
-}
-
-private extension WKWebView {
- func cmuxSetUnitTestInspector(_ inspector: NSObject?) {
- objc_setAssociatedObject(
- self,
- &cmuxUnitTestInspectorAssociationKey,
- inspector,
- .OBJC_ASSOCIATION_RETAIN_NONATOMIC
- )
- }
-}
-
-private func installCmuxUnitTestInspectorOverride() {
- guard !cmuxUnitTestInspectorOverrideInstalled else { return }
-
- guard let replacementMethod = class_getInstanceMethod(
- CmuxWebView.self,
- #selector(CmuxWebView.cmuxUnitTestInspector)
- ) else {
- fatalError("Unable to locate test inspector replacement method")
- }
-
- let added = class_addMethod(
- CmuxWebView.self,
- NSSelectorFromString("_inspector"),
- method_getImplementation(replacementMethod),
- method_getTypeEncoding(replacementMethod)
- )
- guard added else {
- fatalError("Unable to install CmuxWebView _inspector test override")
- }
-
- cmuxUnitTestInspectorOverrideInstalled = true
-}
-
-private func drainMainQueue() {
- let expectation = XCTestExpectation(description: "drain main queue")
- DispatchQueue.main.async {
- expectation.fulfill()
- }
- XCTWaiter().wait(for: [expectation], timeout: 1.0)
-}
-
-@MainActor
-private func makeTemporaryBrowserProfile(named prefix: String) throws -> BrowserProfileDefinition {
- try XCTUnwrap(
- BrowserProfileStore.shared.createProfile(
- named: "\(prefix)-\(UUID().uuidString)"
- )
- )
-}
-
-final class SplitShortcutTransientFocusGuardTests: XCTestCase {
- func testSuppressesWhenFirstResponderFallsBackAndHostedViewIsTiny() {
- XCTAssertTrue(
- shouldSuppressSplitShortcutForTransientTerminalFocusInputs(
- firstResponderIsWindow: true,
- hostedSize: CGSize(width: 79, height: 0),
- hostedHiddenInHierarchy: false,
- hostedAttachedToWindow: true
- )
- )
- }
-
- func testSuppressesWhenFirstResponderFallsBackAndHostedViewIsDetached() {
- XCTAssertTrue(
- shouldSuppressSplitShortcutForTransientTerminalFocusInputs(
- firstResponderIsWindow: true,
- hostedSize: CGSize(width: 1051.5, height: 1207),
- hostedHiddenInHierarchy: false,
- hostedAttachedToWindow: false
- )
- )
- }
-
- func testAllowsWhenFirstResponderFallsBackButGeometryIsHealthy() {
- XCTAssertFalse(
- shouldSuppressSplitShortcutForTransientTerminalFocusInputs(
- firstResponderIsWindow: true,
- hostedSize: CGSize(width: 1051.5, height: 1207),
- hostedHiddenInHierarchy: false,
- hostedAttachedToWindow: true
- )
- )
- }
-
- func testAllowsWhenFirstResponderIsTerminalEvenIfViewIsTiny() {
- XCTAssertFalse(
- shouldSuppressSplitShortcutForTransientTerminalFocusInputs(
- firstResponderIsWindow: false,
- hostedSize: CGSize(width: 79, height: 0),
- hostedHiddenInHierarchy: false,
- hostedAttachedToWindow: true
- )
- )
- }
-}
-
-final class CmuxWebViewKeyEquivalentTests: XCTestCase {
- private final class ActionSpy: NSObject {
- private(set) var invoked: Bool = false
-
- @objc func didInvoke(_ sender: Any?) {
- invoked = true
- }
- }
-
- private final class WindowCyclingActionSpy: NSObject {
- weak var firstWindow: NSWindow?
- weak var secondWindow: NSWindow?
- private(set) var invocationCount = 0
-
- @objc func cycleWindow(_ sender: Any?) {
- invocationCount += 1
- guard let firstWindow, let secondWindow else { return }
-
- if NSApp.keyWindow === firstWindow {
- secondWindow.makeKeyAndOrderFront(nil)
- } else {
- firstWindow.makeKeyAndOrderFront(nil)
- }
- }
- }
-
- private final class FirstResponderView: NSView {
- override var acceptsFirstResponder: Bool { true }
- }
-
- private final class DelegateProbeTextView: NSTextView {
- private(set) var delegateReadCount = 0
-
- override var delegate: NSTextViewDelegate? {
- get {
- delegateReadCount += 1
- return super.delegate
- }
- set {
- super.delegate = newValue
- }
- }
- }
-
- private final class FieldEditorProbeTextView: NSTextView {
- private(set) var delegateReadCount = 0
-
- override var delegate: NSTextViewDelegate? {
- get {
- delegateReadCount += 1
- return super.delegate
- }
- set {
- super.delegate = newValue
- }
- }
-
- override var isFieldEditor: Bool {
- get { true }
- set {}
- }
- }
- func testCmdNRoutesToMainMenuWhenWebViewIsFirstResponder() {
- let spy = ActionSpy()
- installMenu(spy: spy, key: "n", modifiers: [.command])
-
- let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
- let event = makeKeyDownEvent(key: "n", modifiers: [.command], keyCode: 45) // kVK_ANSI_N
- XCTAssertNotNil(event)
-
- XCTAssertTrue(webView.performKeyEquivalent(with: event!))
- XCTAssertTrue(spy.invoked)
- }
-
- func testCmdWRoutesToMainMenuWhenWebViewIsFirstResponder() {
- let spy = ActionSpy()
- installMenu(spy: spy, key: "w", modifiers: [.command])
-
- let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
- let event = makeKeyDownEvent(key: "w", modifiers: [.command], keyCode: 13) // kVK_ANSI_W
- XCTAssertNotNil(event)
-
- XCTAssertTrue(webView.performKeyEquivalent(with: event!))
- XCTAssertTrue(spy.invoked)
- }
-
- func testCmdRRoutesToMainMenuWhenWebViewIsFirstResponder() {
- let spy = ActionSpy()
- installMenu(spy: spy, key: "r", modifiers: [.command])
-
- let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
- let event = makeKeyDownEvent(key: "r", modifiers: [.command], keyCode: 15) // kVK_ANSI_R
- XCTAssertNotNil(event)
-
- XCTAssertTrue(webView.performKeyEquivalent(with: event!))
- XCTAssertTrue(spy.invoked)
- }
-
- func testReturnDoesNotRouteToMainMenuWhenWebViewIsFirstResponder() {
- let spy = ActionSpy()
- installMenu(spy: spy, key: "\r", modifiers: [])
-
- let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
- let event = makeKeyDownEvent(key: "\r", modifiers: [], keyCode: 36) // kVK_Return
- XCTAssertNotNil(event)
-
- XCTAssertFalse(webView.performKeyEquivalent(with: event!))
- XCTAssertFalse(spy.invoked)
- }
-
- func testCmdReturnDoesNotRouteToMainMenuWhenWebViewIsFirstResponder() {
- let spy = ActionSpy()
- installMenu(spy: spy, key: "\r", modifiers: [.command])
-
- let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
- let event = makeKeyDownEvent(key: "\r", modifiers: [.command], keyCode: 36) // kVK_Return
- XCTAssertNotNil(event)
-
- XCTAssertFalse(webView.performKeyEquivalent(with: event!))
- XCTAssertFalse(spy.invoked)
- }
-
- func testKeypadEnterDoesNotRouteToMainMenuWhenWebViewIsFirstResponder() {
- let spy = ActionSpy()
- installMenu(spy: spy, key: "\r", modifiers: [])
-
- let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
- let event = makeKeyDownEvent(key: "\r", modifiers: [], keyCode: 76) // kVK_ANSI_KeypadEnter
- XCTAssertNotNil(event)
-
- XCTAssertFalse(webView.performKeyEquivalent(with: event!))
- XCTAssertFalse(spy.invoked)
- }
-
- @MainActor
- func testCanBlockFirstResponderAcquisitionWhenPaneIsUnfocused() {
- _ = NSApplication.shared
-
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
- window.contentView = container
-
- let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration())
- webView.autoresizingMask = [.width, .height]
- container.addSubview(webView)
-
- window.makeKeyAndOrderFront(nil)
- defer { window.orderOut(nil) }
-
- webView.allowsFirstResponderAcquisition = true
- XCTAssertTrue(window.makeFirstResponder(webView))
-
- _ = window.makeFirstResponder(nil)
- webView.allowsFirstResponderAcquisition = false
- XCTAssertFalse(webView.becomeFirstResponder())
-
- _ = window.makeFirstResponder(webView)
- if let firstResponderView = window.firstResponder as? NSView {
- XCTAssertFalse(firstResponderView === webView || firstResponderView.isDescendant(of: webView))
- }
- }
-
- @MainActor
- func testPointerFocusAllowanceCanTemporarilyOverrideBlockedFirstResponderAcquisition() {
- _ = NSApplication.shared
-
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
- window.contentView = container
-
- let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration())
- webView.autoresizingMask = [.width, .height]
- container.addSubview(webView)
-
- window.makeKeyAndOrderFront(nil)
- defer { window.orderOut(nil) }
-
- webView.allowsFirstResponderAcquisition = false
- _ = window.makeFirstResponder(nil)
- XCTAssertFalse(webView.becomeFirstResponder(), "Expected focus to stay blocked by policy")
-
- webView.withPointerFocusAllowance {
- XCTAssertTrue(webView.becomeFirstResponder(), "Expected explicit pointer intent to bypass policy")
- }
-
- _ = window.makeFirstResponder(nil)
- XCTAssertFalse(webView.becomeFirstResponder(), "Expected pointer allowance to be temporary")
- }
-
- @MainActor
- func testWindowFirstResponderGuardBlocksDescendantWhenPaneIsUnfocused() {
- _ = NSApplication.shared
- AppDelegate.installWindowResponderSwizzlesForTesting()
-
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
- window.contentView = container
-
- let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration())
- webView.autoresizingMask = [.width, .height]
- container.addSubview(webView)
-
- let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10))
- webView.addSubview(descendant)
-
- window.makeKeyAndOrderFront(nil)
- defer { window.orderOut(nil) }
-
- webView.allowsFirstResponderAcquisition = true
- XCTAssertTrue(window.makeFirstResponder(descendant))
-
- _ = window.makeFirstResponder(nil)
- webView.allowsFirstResponderAcquisition = false
- XCTAssertFalse(window.makeFirstResponder(descendant))
-
- if let firstResponderView = window.firstResponder as? NSView {
- XCTAssertFalse(firstResponderView === descendant || firstResponderView.isDescendant(of: webView))
- }
- }
-
- @MainActor
- func testWindowFirstResponderGuardAllowsDescendantDuringPointerFocusAllowance() {
- _ = NSApplication.shared
- AppDelegate.installWindowResponderSwizzlesForTesting()
-
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
- window.contentView = container
-
- let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration())
- webView.autoresizingMask = [.width, .height]
- container.addSubview(webView)
-
- let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10))
- webView.addSubview(descendant)
-
- window.makeKeyAndOrderFront(nil)
- defer { window.orderOut(nil) }
-
- webView.allowsFirstResponderAcquisition = false
- _ = window.makeFirstResponder(nil)
- XCTAssertFalse(window.makeFirstResponder(descendant), "Expected blocked focus outside pointer allowance")
-
- _ = window.makeFirstResponder(nil)
- webView.withPointerFocusAllowance {
- XCTAssertTrue(window.makeFirstResponder(descendant), "Expected pointer allowance to bypass guard")
- }
-
- _ = window.makeFirstResponder(nil)
- XCTAssertFalse(window.makeFirstResponder(descendant), "Expected pointer allowance to remain temporary")
- }
-
- @MainActor
- func testWindowFirstResponderGuardAllowsPointerInitiatedClickFocusWhenPolicyIsBlocked() {
- _ = NSApplication.shared
- AppDelegate.installWindowResponderSwizzlesForTesting()
-
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
- window.contentView = container
-
- let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration())
- webView.autoresizingMask = [.width, .height]
- container.addSubview(webView)
-
- let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10))
- webView.addSubview(descendant)
-
- window.makeKeyAndOrderFront(nil)
- defer {
- AppDelegate.clearWindowFirstResponderGuardTesting()
- window.orderOut(nil)
- }
-
- webView.allowsFirstResponderAcquisition = false
- _ = window.makeFirstResponder(nil)
- XCTAssertFalse(window.makeFirstResponder(descendant), "Expected blocked focus without pointer click context")
-
- let timestamp = ProcessInfo.processInfo.systemUptime
- let pointerDownEvent = NSEvent.mouseEvent(
- with: .leftMouseDown,
- location: NSPoint(x: 5, y: 5),
- modifierFlags: [],
- timestamp: timestamp,
- windowNumber: window.windowNumber,
- context: nil,
- eventNumber: 1,
- clickCount: 1,
- pressure: 1.0
- )
- XCTAssertNotNil(pointerDownEvent)
-
- AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: pointerDownEvent, hitView: descendant)
- _ = window.makeFirstResponder(nil)
- XCTAssertTrue(window.makeFirstResponder(descendant), "Expected pointer click context to bypass blocked policy")
-
- AppDelegate.clearWindowFirstResponderGuardTesting()
- _ = window.makeFirstResponder(nil)
- XCTAssertFalse(window.makeFirstResponder(descendant), "Expected pointer bypass to be limited to click context")
- }
-
- @MainActor
- func testWindowFirstResponderGuardAllowsPointerInitiatedClickFocusFromPortalHostedInspectorSibling() {
- _ = NSApplication.shared
- AppDelegate.installWindowResponderSwizzlesForTesting()
-
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- let contentView = NSView(frame: window.contentRect(forFrameRect: window.frame))
- window.contentView = contentView
-
- window.makeKeyAndOrderFront(nil)
- defer {
- AppDelegate.clearWindowFirstResponderGuardTesting()
- window.orderOut(nil)
- }
-
- guard let container = contentView.superview else {
- XCTFail("Expected content container")
- return
- }
-
- let hostFrame = container.convert(contentView.bounds, from: contentView)
- let host = WindowBrowserHostView(frame: hostFrame)
- host.autoresizingMask = [.width, .height]
- container.addSubview(host, positioned: .above, relativeTo: contentView)
-
- let slot = WindowBrowserSlotView(frame: host.bounds)
- slot.autoresizingMask = [.width, .height]
- host.addSubview(slot)
-
- let webView = CmuxWebView(frame: slot.bounds, configuration: WKWebViewConfiguration())
- webView.autoresizingMask = [.width, .height]
- slot.addSubview(webView)
-
- let inspector = FirstResponderView(frame: NSRect(x: 440, y: 0, width: 200, height: slot.bounds.height))
- inspector.autoresizingMask = [.minXMargin, .height]
- slot.addSubview(inspector)
-
- webView.allowsFirstResponderAcquisition = false
- _ = window.makeFirstResponder(nil)
- XCTAssertFalse(
- window.makeFirstResponder(inspector),
- "Expected portal-hosted inspector focus to stay blocked without pointer click context"
- )
-
- let pointInInspector = NSPoint(x: inspector.bounds.midX, y: inspector.bounds.midY)
- let pointInWindow = inspector.convert(pointInInspector, to: nil)
- let pointerDownEvent = NSEvent.mouseEvent(
- with: .leftMouseDown,
- location: pointInWindow,
- modifierFlags: [],
- timestamp: ProcessInfo.processInfo.systemUptime,
- windowNumber: window.windowNumber,
- context: nil,
- eventNumber: 1,
- clickCount: 1,
- pressure: 1.0
- )
- XCTAssertNotNil(pointerDownEvent)
-
- AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: pointerDownEvent, hitView: nil)
- _ = window.makeFirstResponder(nil)
- XCTAssertTrue(
- window.makeFirstResponder(inspector),
- "Expected portal-hosted inspector click to bypass blocked policy using the overlay hit target"
- )
- }
-
- @MainActor
- func testWindowFirstResponderGuardAllowsPointerInitiatedClickFocusFromBoundPortalInspectorSiblingWhenHitTestMisses() {
- _ = NSApplication.shared
- AppDelegate.installWindowResponderSwizzlesForTesting()
-
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- let contentView = NSView(frame: window.contentRect(forFrameRect: window.frame))
- window.contentView = contentView
-
- let anchor = NSView(frame: NSRect(x: 80, y: 60, width: 480, height: 260))
- contentView.addSubview(anchor)
-
- let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
-
- window.makeKeyAndOrderFront(nil)
- contentView.layoutSubtreeIfNeeded()
- RunLoop.current.run(until: Date().addingTimeInterval(0.05))
- BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true, zPriority: 1)
- BrowserWindowPortalRegistry.synchronizeForAnchor(anchor)
-
- defer {
- BrowserWindowPortalRegistry.detach(webView: webView)
- AppDelegate.clearWindowFirstResponderGuardTesting()
- window.orderOut(nil)
- }
-
- guard let slot = webView.superview as? WindowBrowserSlotView else {
- XCTFail("Expected bound portal slot")
- return
- }
-
- let inspector = FirstResponderView(frame: NSRect(x: 320, y: 0, width: 160, height: slot.bounds.height))
- inspector.autoresizingMask = [.minXMargin, .height]
- slot.addSubview(inspector)
-
- webView.allowsFirstResponderAcquisition = false
- _ = window.makeFirstResponder(nil)
- XCTAssertFalse(
- window.makeFirstResponder(inspector),
- "Expected bound portal inspector focus to stay blocked without pointer click context"
- )
-
- let pointInInspector = NSPoint(x: inspector.bounds.midX, y: inspector.bounds.midY)
- let pointInWindow = inspector.convert(pointInInspector, to: nil)
- XCTAssertTrue(
- BrowserWindowPortalRegistry.webViewAtWindowPoint(pointInWindow, in: window) === webView,
- "Expected portal registry to resolve the owning web view from a click inside inspector chrome"
- )
-
- let pointerDownEvent = NSEvent.mouseEvent(
- with: .leftMouseDown,
- location: pointInWindow,
- modifierFlags: [],
- timestamp: ProcessInfo.processInfo.systemUptime,
- windowNumber: window.windowNumber,
- context: nil,
- eventNumber: 1,
- clickCount: 1,
- pressure: 1.0
- )
- XCTAssertNotNil(pointerDownEvent)
-
- AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: pointerDownEvent, hitView: nil)
- _ = window.makeFirstResponder(nil)
- XCTAssertTrue(
- window.makeFirstResponder(inspector),
- "Expected bound portal inspector click to bypass blocked policy through portal registry fallback"
- )
- }
-
- @MainActor
- func testWindowFirstResponderGuardAvoidsTextViewDelegateLookupForWebViewResolution() {
- _ = NSApplication.shared
- AppDelegate.installWindowResponderSwizzlesForTesting()
-
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
- window.contentView = container
-
- let textView = DelegateProbeTextView(frame: NSRect(x: 0, y: 0, width: 100, height: 40))
- container.addSubview(textView)
-
- window.makeKeyAndOrderFront(nil)
- defer { window.orderOut(nil) }
-
- _ = window.makeFirstResponder(nil)
- _ = window.makeFirstResponder(textView)
-
- XCTAssertEqual(
- textView.delegateReadCount,
- 0,
- "WebView ownership resolution should not touch NSTextView.delegate (unsafe-unretained in AppKit)"
- )
- }
-
- @MainActor
- func testWindowFirstResponderGuardResolvesTrackedWebViewForFieldEditorResponder() {
- _ = NSApplication.shared
- AppDelegate.installWindowResponderSwizzlesForTesting()
-
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
- window.contentView = container
-
- let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration())
- webView.autoresizingMask = [.width, .height]
- container.addSubview(webView)
-
- let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10))
- webView.addSubview(descendant)
-
- let fieldEditor = FieldEditorProbeTextView(frame: NSRect(x: 0, y: 0, width: 100, height: 20))
-
- window.makeKeyAndOrderFront(nil)
- defer {
- AppDelegate.clearWindowFirstResponderGuardTesting()
- window.orderOut(nil)
- }
-
- webView.allowsFirstResponderAcquisition = true
- XCTAssertTrue(window.makeFirstResponder(descendant))
-
- let timestamp = ProcessInfo.processInfo.systemUptime
- let pointerDownEvent = NSEvent.mouseEvent(
- with: .leftMouseDown,
- location: NSPoint(x: 5, y: 5),
- modifierFlags: [],
- timestamp: timestamp,
- windowNumber: window.windowNumber,
- context: nil,
- eventNumber: 1,
- clickCount: 1,
- pressure: 1.0
- )
- XCTAssertNotNil(pointerDownEvent)
-
- AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: pointerDownEvent, hitView: descendant)
- XCTAssertTrue(window.makeFirstResponder(fieldEditor))
-
- AppDelegate.clearWindowFirstResponderGuardTesting()
- _ = window.makeFirstResponder(nil)
- webView.allowsFirstResponderAcquisition = false
- XCTAssertFalse(window.makeFirstResponder(fieldEditor))
- XCTAssertEqual(
- fieldEditor.delegateReadCount,
- 0,
- "Field-editor webview ownership should come from tracked associations, not NSTextView.delegate"
- )
- }
-
- @MainActor
- func testWindowFirstResponderBypassBlocksSwizzledMakeFirstResponder() {
- _ = NSApplication.shared
- AppDelegate.installWindowResponderSwizzlesForTesting()
-
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
- window.contentView = container
-
- let responder = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 80, height: 40))
- container.addSubview(responder)
-
- window.makeKeyAndOrderFront(nil)
- defer { window.orderOut(nil) }
-
- _ = window.makeFirstResponder(nil)
- cmuxWithWindowFirstResponderBypass {
- XCTAssertFalse(
- window.makeFirstResponder(responder),
- "Bypass scope should block transient first-responder changes during devtools auto-restore"
- )
- }
- XCTAssertTrue(window.makeFirstResponder(responder))
- }
-
- @MainActor
- func testCmdBacktickMenuActionThatChangesKeyWindowOnlyRunsOnceWhenTerminalIsFirstResponder() {
- _ = NSApplication.shared
- AppDelegate.installWindowResponderSwizzlesForTesting()
-
- let firstWindow = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- let secondWindow = NSWindow(
- contentRect: NSRect(x: 40, y: 40, width: 640, height: 420),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
-
- let firstContainer = NSView(frame: firstWindow.contentRect(forFrameRect: firstWindow.frame))
- let secondContainer = NSView(frame: secondWindow.contentRect(forFrameRect: secondWindow.frame))
- firstWindow.contentView = firstContainer
- secondWindow.contentView = secondContainer
-
- let firstTerminal = GhosttyNSView(frame: firstContainer.bounds)
- firstTerminal.autoresizingMask = [.width, .height]
- firstContainer.addSubview(firstTerminal)
-
- let secondTerminal = GhosttyNSView(frame: secondContainer.bounds)
- secondTerminal.autoresizingMask = [.width, .height]
- secondContainer.addSubview(secondTerminal)
-
- let spy = WindowCyclingActionSpy()
- spy.firstWindow = firstWindow
- spy.secondWindow = secondWindow
- installMenu(
- target: spy,
- action: #selector(WindowCyclingActionSpy.cycleWindow(_:)),
- key: "`",
- modifiers: [.command]
- )
-
- secondWindow.orderFront(nil)
- firstWindow.makeKeyAndOrderFront(nil)
- defer {
- secondWindow.orderOut(nil)
- firstWindow.orderOut(nil)
- }
-
- XCTAssertTrue(firstWindow.makeFirstResponder(firstTerminal))
- guard let event = makeKeyDownEvent(
- key: "`",
- modifiers: [.command],
- keyCode: 50,
- windowNumber: firstWindow.windowNumber
- ) else {
- XCTFail("Failed to construct Cmd+` event")
- return
- }
-
- NSApp.sendEvent(event)
- RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
-
- XCTAssertEqual(spy.invocationCount, 1, "Cmd+` should only trigger one window-cycle action")
- }
-
- @MainActor
- func testCmdBacktickDoesNotRouteDirectlyToMainMenuWhenWebViewIsFirstResponder() {
- _ = NSApplication.shared
-
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
-
- let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
- window.contentView = container
-
- let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration())
- webView.autoresizingMask = [.width, .height]
- container.addSubview(webView)
-
- let spy = ActionSpy()
- installMenu(
- target: spy,
- action: #selector(ActionSpy.didInvoke(_:)),
- key: "`",
- modifiers: [.command]
- )
-
- window.makeKeyAndOrderFront(nil)
- defer {
- window.orderOut(nil)
- }
-
- XCTAssertTrue(window.makeFirstResponder(webView))
- guard let event = makeKeyDownEvent(
- key: "`",
- modifiers: [.command],
- keyCode: 50,
- windowNumber: window.windowNumber
- ) else {
- XCTFail("Failed to construct Cmd+` event")
- return
- }
-
- XCTAssertFalse(shouldRouteCommandEquivalentDirectlyToMainMenu(event))
- _ = webView.performKeyEquivalent(with: event)
- XCTAssertFalse(
- spy.invoked,
- "CmuxWebView should not route Cmd+` directly to the menu when WebKit is first responder"
- )
- }
-
- private func installMenu(spy: ActionSpy, key: String, modifiers: NSEvent.ModifierFlags) {
- installMenu(
- target: spy,
- action: #selector(ActionSpy.didInvoke(_:)),
- key: key,
- modifiers: modifiers
- )
- }
-
- private func installMenu(
- target: NSObject,
- action: Selector,
- key: String,
- modifiers: NSEvent.ModifierFlags
- ) {
- let mainMenu = NSMenu()
-
- let fileItem = NSMenuItem(title: "File", action: nil, keyEquivalent: "")
- let fileMenu = NSMenu(title: "File")
-
- let item = NSMenuItem(title: "Test Item", action: action, keyEquivalent: key)
- item.keyEquivalentModifierMask = modifiers
- item.target = target
- fileMenu.addItem(item)
-
- mainMenu.addItem(fileItem)
- mainMenu.setSubmenu(fileMenu, for: fileItem)
-
- // Ensure NSApp exists and has a menu for performKeyEquivalent to consult.
- _ = NSApplication.shared
- NSApp.mainMenu = mainMenu
- }
-
- private func makeKeyDownEvent(
- key: String,
- modifiers: NSEvent.ModifierFlags,
- keyCode: UInt16,
- windowNumber: Int = 0
- ) -> 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
- )
- }
-}
-
-@MainActor
-final class GhosttyPasteboardHelperTests: XCTestCase {
- func testHTMLOnlyPasteboardExtractsPlainText() {
- let pasteboard = NSPasteboard(name: .init("cmux-test-html-\(UUID().uuidString)"))
- pasteboard.clearContents()
- pasteboard.setString("Hello world
", forType: .html)
-
- XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello world")
- XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard))
- }
-
- func testImageHTMLClipboardFallsBackToImagePath() throws {
- let pasteboard = NSPasteboard(name: .init("cmux-test-image-html-\(UUID().uuidString)"))
- pasteboard.clearContents()
- pasteboard.setString("
", forType: .html)
-
- let image = NSImage(size: NSSize(width: 1, height: 1))
- image.lockFocus()
- NSColor.red.setFill()
- NSRect(x: 0, y: 0, width: 1, height: 1).fill()
- image.unlockFocus()
- let tiffData = try XCTUnwrap(image.tiffRepresentation)
- let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData))
- let pngData = try XCTUnwrap(bitmap.representation(using: .png, properties: [:]))
- pasteboard.setData(pngData, forType: .png)
-
- XCTAssertNil(cmuxPasteboardStringContentsForTesting(pasteboard))
-
- let imagePath = try XCTUnwrap(cmuxPasteboardImagePathForTesting(pasteboard))
- defer { try? FileManager.default.removeItem(atPath: imagePath) }
-
- XCTAssertTrue(imagePath.hasSuffix(".png"))
- XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath))
- }
-
- func testImageHTMLClipboardWithVisibleTextPrefersText() throws {
- let pasteboard = NSPasteboard(name: .init("cmux-test-image-html-text-\(UUID().uuidString)"))
- pasteboard.clearContents()
- pasteboard.setString("Hello 
", forType: .html)
-
- let image = NSImage(size: NSSize(width: 1, height: 1))
- image.lockFocus()
- NSColor.blue.setFill()
- NSRect(x: 0, y: 0, width: 1, height: 1).fill()
- image.unlockFocus()
- let tiffData = try XCTUnwrap(image.tiffRepresentation)
- let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData))
- let pngData = try XCTUnwrap(bitmap.representation(using: .png, properties: [:]))
- pasteboard.setData(pngData, forType: .png)
-
- XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello")
- XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard))
- }
-
- func testJPEGClipboardFallsBackToImagePath() throws {
- let pasteboard = NSPasteboard(name: .init("cmux-test-jpeg-\(UUID().uuidString)"))
- pasteboard.clearContents()
-
- let image = NSImage(size: NSSize(width: 1, height: 1))
- image.lockFocus()
- NSColor.green.setFill()
- NSRect(x: 0, y: 0, width: 1, height: 1).fill()
- image.unlockFocus()
-
- let tiffData = try XCTUnwrap(image.tiffRepresentation)
- let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData))
- let jpegData = try XCTUnwrap(
- bitmap.representation(
- using: .jpeg,
- properties: [.compressionFactor: 1.0]
- )
- )
- pasteboard.setData(
- jpegData,
- forType: NSPasteboard.PasteboardType(UTType.jpeg.identifier)
- )
-
- let imagePath = try XCTUnwrap(cmuxPasteboardImagePathForTesting(pasteboard))
- defer { try? FileManager.default.removeItem(atPath: imagePath) }
-
- XCTAssertTrue(imagePath.hasSuffix(".jpeg"))
- XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath))
- }
-
- func testAttachmentOnlyRTFDClipboardFallsBackToImagePath() throws {
- let pasteboard = NSPasteboard(name: .init("cmux-test-rtfd-attachment-\(UUID().uuidString)"))
- pasteboard.clearContents()
-
- let image = NSImage(size: NSSize(width: 1, height: 1))
- image.lockFocus()
- NSColor.orange.setFill()
- NSRect(x: 0, y: 0, width: 1, height: 1).fill()
- image.unlockFocus()
-
- let attachment = NSTextAttachment()
- attachment.image = image
- let attributed = NSAttributedString(attachment: attachment)
- let data = try attributed.data(
- from: NSRange(location: 0, length: attributed.length),
- documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd]
- )
- pasteboard.setData(data, forType: .rtfd)
-
- XCTAssertNil(cmuxPasteboardStringContentsForTesting(pasteboard))
-
- let imagePath = try XCTUnwrap(cmuxPasteboardImagePathForTesting(pasteboard))
- defer { try? FileManager.default.removeItem(atPath: imagePath) }
-
- XCTAssertTrue(imagePath.hasSuffix(".tiff"))
- XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath))
- }
-
- func testAttachmentOnlyRTFDNonImageClipboardDoesNotFallBackToImagePath() throws {
- let pasteboard = NSPasteboard(name: .init("cmux-test-rtfd-non-image-\(UUID().uuidString)"))
- pasteboard.clearContents()
-
- let wrapper = FileWrapper(regularFileWithContents: Data("hello".utf8))
- wrapper.preferredFilename = "note.txt"
-
- let attachment = NSTextAttachment(fileWrapper: wrapper)
- let attributed = NSAttributedString(attachment: attachment)
- let data = try attributed.data(
- from: NSRange(location: 0, length: attributed.length),
- documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd]
- )
- pasteboard.setData(data, forType: .rtfd)
-
- XCTAssertNil(cmuxPasteboardStringContentsForTesting(pasteboard))
- XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard))
- }
-
- func testRTFDClipboardWithVisibleTextPrefersText() throws {
- let pasteboard = NSPasteboard(name: .init("cmux-test-rtfd-text-\(UUID().uuidString)"))
- pasteboard.clearContents()
-
- let image = NSImage(size: NSSize(width: 1, height: 1))
- image.lockFocus()
- NSColor.purple.setFill()
- NSRect(x: 0, y: 0, width: 1, height: 1).fill()
- image.unlockFocus()
-
- let attachment = NSTextAttachment()
- attachment.image = image
-
- let attributed = NSMutableAttributedString(string: "Hello ")
- attributed.append(NSAttributedString(attachment: attachment))
- let data = try attributed.data(
- from: NSRange(location: 0, length: attributed.length),
- documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd]
- )
- pasteboard.setData(data, forType: .rtfd)
-
- XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello")
- XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard))
- }
-}
-
-@MainActor
-final class AppDelegateWindowContextRoutingTests: XCTestCase {
- private func makeMainWindow(id: UUID) -> NSWindow {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- window.identifier = NSUserInterfaceItemIdentifier("cmux.main.\(id.uuidString)")
- return window
- }
-
- func testSynchronizeActiveMainWindowContextPrefersProvidedWindowOverStaleActiveManager() {
- _ = NSApplication.shared
- let app = AppDelegate()
-
- let windowAId = UUID()
- let windowBId = UUID()
- let windowA = makeMainWindow(id: windowAId)
- let windowB = makeMainWindow(id: windowBId)
- defer {
- windowA.orderOut(nil)
- windowB.orderOut(nil)
- }
-
- let managerA = TabManager()
- let managerB = TabManager()
- app.registerMainWindow(
- windowA,
- windowId: windowAId,
- tabManager: managerA,
- sidebarState: SidebarState(),
- sidebarSelectionState: SidebarSelectionState()
- )
- app.registerMainWindow(
- windowB,
- windowId: windowBId,
- tabManager: managerB,
- sidebarState: SidebarState(),
- sidebarSelectionState: SidebarSelectionState()
- )
-
- windowB.makeKeyAndOrderFront(nil)
- _ = app.synchronizeActiveMainWindowContext(preferredWindow: windowB)
- XCTAssertTrue(app.tabManager === managerB)
-
- windowA.makeKeyAndOrderFront(nil)
- let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: windowA)
- XCTAssertTrue(resolved === managerA, "Expected provided active window to win over stale active manager")
- XCTAssertTrue(app.tabManager === managerA)
- }
-
- func testSynchronizeActiveMainWindowContextFallsBackToActiveManagerWithoutFocusedWindow() {
- _ = NSApplication.shared
- let app = AppDelegate()
-
- let windowAId = UUID()
- let windowBId = UUID()
- let windowA = makeMainWindow(id: windowAId)
- let windowB = makeMainWindow(id: windowBId)
- defer {
- windowA.orderOut(nil)
- windowB.orderOut(nil)
- }
-
- let managerA = TabManager()
- let managerB = TabManager()
- app.registerMainWindow(
- windowA,
- windowId: windowAId,
- tabManager: managerA,
- sidebarState: SidebarState(),
- sidebarSelectionState: SidebarSelectionState()
- )
- app.registerMainWindow(
- windowB,
- windowId: windowBId,
- tabManager: managerB,
- sidebarState: SidebarState(),
- sidebarSelectionState: SidebarSelectionState()
- )
-
- // Seed active manager and clear focus windows to force fallback routing.
- windowA.makeKeyAndOrderFront(nil)
- _ = app.synchronizeActiveMainWindowContext(preferredWindow: windowA)
- XCTAssertTrue(app.tabManager === managerA)
- windowA.orderOut(nil)
- windowB.orderOut(nil)
-
- let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: nil)
- XCTAssertTrue(resolved === managerA, "Expected fallback to preserve current active manager instead of arbitrary window")
- XCTAssertTrue(app.tabManager === managerA)
- }
-
- func testSynchronizeActiveMainWindowContextUsesRegisteredWindowEvenIfIdentifierMutates() {
- _ = NSApplication.shared
- let app = AppDelegate()
-
- let windowId = UUID()
- let window = makeMainWindow(id: windowId)
- defer { window.orderOut(nil) }
-
- let manager = TabManager()
- app.registerMainWindow(
- window,
- windowId: windowId,
- tabManager: manager,
- sidebarState: SidebarState(),
- sidebarSelectionState: SidebarSelectionState()
- )
-
- // SwiftUI can replace the NSWindow identifier string at runtime.
- window.identifier = NSUserInterfaceItemIdentifier("SwiftUI.AppWindow.IdentifierChanged")
-
- let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: window)
- XCTAssertTrue(resolved === manager, "Expected registered window object identity to win even if identifier string changed")
- XCTAssertTrue(app.tabManager === manager)
- }
-
- func testAddWorkspaceWithoutBringToFrontPreservesActiveWindowAndSelection() {
- _ = NSApplication.shared
- let app = AppDelegate()
-
- let windowAId = UUID()
- let windowBId = UUID()
- let windowA = makeMainWindow(id: windowAId)
- let windowB = makeMainWindow(id: windowBId)
- defer {
- windowA.orderOut(nil)
- windowB.orderOut(nil)
- }
-
- let managerA = TabManager()
- let managerB = TabManager()
- app.registerMainWindow(
- windowA,
- windowId: windowAId,
- tabManager: managerA,
- sidebarState: SidebarState(),
- sidebarSelectionState: SidebarSelectionState()
- )
- app.registerMainWindow(
- windowB,
- windowId: windowBId,
- tabManager: managerB,
- sidebarState: SidebarState(),
- sidebarSelectionState: SidebarSelectionState()
- )
-
- windowA.makeKeyAndOrderFront(nil)
- _ = app.synchronizeActiveMainWindowContext(preferredWindow: windowA)
- XCTAssertTrue(app.tabManager === managerA)
-
- let originalSelectedA = managerA.selectedTabId
- let originalSelectedB = managerB.selectedTabId
- let originalTabCountB = managerB.tabs.count
-
- let createdWorkspaceId = app.addWorkspace(windowId: windowBId, bringToFront: false)
-
- XCTAssertNotNil(createdWorkspaceId)
- XCTAssertTrue(app.tabManager === managerA, "Expected non-focus workspace creation to preserve active window routing")
- XCTAssertEqual(managerA.selectedTabId, originalSelectedA)
- XCTAssertEqual(managerB.selectedTabId, originalSelectedB, "Expected background workspace creation to preserve selected tab")
- XCTAssertEqual(managerB.tabs.count, originalTabCountB + 1)
- XCTAssertTrue(managerB.tabs.contains(where: { $0.id == createdWorkspaceId }))
- }
-
- func testApplicationOpenURLsAddsWorkspaceForDroppedFolderURL() throws {
- _ = NSApplication.shared
- let app = AppDelegate()
-
- let windowId = UUID()
- let window = makeMainWindow(id: windowId)
- defer { window.orderOut(nil) }
-
- let manager = TabManager()
- app.registerMainWindow(
- window,
- windowId: windowId,
- tabManager: manager,
- sidebarState: SidebarState(),
- sidebarSelectionState: SidebarSelectionState()
- )
-
- window.makeKeyAndOrderFront(nil)
- _ = app.synchronizeActiveMainWindowContext(preferredWindow: window)
-
- let defaults = UserDefaults.standard
- let previousWelcomeShown = defaults.object(forKey: WelcomeSettings.shownKey)
- defaults.set(true, forKey: WelcomeSettings.shownKey)
- defer {
- if let previousWelcomeShown {
- defaults.set(previousWelcomeShown, forKey: WelcomeSettings.shownKey)
- } else {
- defaults.removeObject(forKey: WelcomeSettings.shownKey)
- }
- }
-
- let rootDirectory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
- .appendingPathComponent(UUID().uuidString, isDirectory: true)
- let droppedDirectory = rootDirectory.appendingPathComponent("project", isDirectory: true)
- try FileManager.default.createDirectory(at: droppedDirectory, withIntermediateDirectories: true)
- defer { try? FileManager.default.removeItem(at: rootDirectory) }
-
- let existingWorkspaceIds = Set(manager.tabs.map(\.id))
-
- app.application(
- NSApplication.shared,
- open: [URL(fileURLWithPath: droppedDirectory.path)]
- )
-
- let createdWorkspace = manager.tabs.first { !existingWorkspaceIds.contains($0.id) }
- XCTAssertNotNil(createdWorkspace)
- XCTAssertEqual(createdWorkspace?.currentDirectory, droppedDirectory.path)
- }
-}
-
-@MainActor
-final class AppDelegateLaunchServicesRegistrationTests: XCTestCase {
- func testScheduleLaunchServicesRegistrationDefersRegisterWork() {
- _ = NSApplication.shared
- let app = AppDelegate()
-
- var scheduledWork: (@Sendable () -> Void)?
- var registerCallCount = 0
-
- app.scheduleLaunchServicesBundleRegistrationForTesting(
- bundleURL: URL(fileURLWithPath: "/tmp/../tmp/cmux-launch-services-test.app"),
- scheduler: { work in
- scheduledWork = work
- },
- register: { _ in
- registerCallCount += 1
- return noErr
- }
- )
-
- XCTAssertEqual(registerCallCount, 0, "Registration should not run inline on the startup call path")
- XCTAssertNotNil(scheduledWork, "Registration work should be handed to the scheduler")
-
- scheduledWork?()
-
- XCTAssertEqual(registerCallCount, 1)
- }
-}
-
-final class FocusFlashPatternTests: XCTestCase {
- func testFocusFlashPatternMatchesTerminalDoublePulseShape() {
- XCTAssertEqual(FocusFlashPattern.values, [0, 1, 0, 1, 0])
- XCTAssertEqual(FocusFlashPattern.keyTimes, [0, 0.25, 0.5, 0.75, 1])
- XCTAssertEqual(FocusFlashPattern.duration, 0.9, accuracy: 0.0001)
- XCTAssertEqual(FocusFlashPattern.curves, [.easeOut, .easeIn, .easeOut, .easeIn])
- XCTAssertEqual(FocusFlashPattern.ringInset, 6, accuracy: 0.0001)
- XCTAssertEqual(FocusFlashPattern.ringCornerRadius, 10, accuracy: 0.0001)
- }
-
- func testFocusFlashPatternSegmentsCoverFullDoublePulseTimeline() {
- let segments = FocusFlashPattern.segments
- XCTAssertEqual(segments.count, 4)
-
- XCTAssertEqual(segments[0].delay, 0.0, accuracy: 0.0001)
- XCTAssertEqual(segments[0].duration, 0.225, accuracy: 0.0001)
- XCTAssertEqual(segments[0].targetOpacity, 1, accuracy: 0.0001)
- XCTAssertEqual(segments[0].curve, .easeOut)
-
- XCTAssertEqual(segments[1].delay, 0.225, accuracy: 0.0001)
- XCTAssertEqual(segments[1].duration, 0.225, accuracy: 0.0001)
- XCTAssertEqual(segments[1].targetOpacity, 0, accuracy: 0.0001)
- XCTAssertEqual(segments[1].curve, .easeIn)
-
- XCTAssertEqual(segments[2].delay, 0.45, accuracy: 0.0001)
- XCTAssertEqual(segments[2].duration, 0.225, accuracy: 0.0001)
- XCTAssertEqual(segments[2].targetOpacity, 1, accuracy: 0.0001)
- XCTAssertEqual(segments[2].curve, .easeOut)
-
- XCTAssertEqual(segments[3].delay, 0.675, accuracy: 0.0001)
- XCTAssertEqual(segments[3].duration, 0.225, accuracy: 0.0001)
- XCTAssertEqual(segments[3].targetOpacity, 0, accuracy: 0.0001)
- XCTAssertEqual(segments[3].curve, .easeIn)
- }
-}
-
-@MainActor
-final class CmuxWebViewContextMenuTests: XCTestCase {
- private func makeRightMouseDownEvent() -> NSEvent {
- guard let event = NSEvent.mouseEvent(
- with: .rightMouseDown,
- location: .zero,
- modifierFlags: [],
- timestamp: ProcessInfo.processInfo.systemUptime,
- windowNumber: 0,
- context: nil,
- eventNumber: 0,
- clickCount: 1,
- pressure: 1.0
- ) else {
- fatalError("Failed to create rightMouseDown event")
- }
- return event
- }
-
- func testWillOpenMenuAddsOpenLinkInDefaultBrowserAndRoutesSelectionToDefaultBrowserOpener() {
- _ = NSApplication.shared
- let webView = CmuxWebView(frame: NSRect(x: 0, y: 0, width: 800, height: 600), configuration: WKWebViewConfiguration())
- let menu = NSMenu()
- let openLinkItem = NSMenuItem(title: "Open Link", action: nil, keyEquivalent: "")
- openLinkItem.identifier = NSUserInterfaceItemIdentifier("WKMenuItemIdentifierOpenLink")
- menu.addItem(openLinkItem)
- menu.addItem(NSMenuItem(title: "Copy Link", action: nil, keyEquivalent: ""))
-
- var openedURL: URL?
- webView.contextMenuLinkURLProvider = { _, _, completion in
- completion(URL(string: "https://example.com/docs")!)
- }
- webView.contextMenuDefaultBrowserOpener = { url in
- openedURL = url
- return true
- }
-
- webView.willOpenMenu(menu, with: makeRightMouseDownEvent())
-
- guard let defaultBrowserItemIndex = menu.items.firstIndex(where: { $0.title == "Open Link in Default Browser" }) else {
- XCTFail("Expected Open Link in Default Browser item in context menu")
- return
- }
- guard let openLinkIndex = menu.items.firstIndex(where: { $0.identifier?.rawValue == "WKMenuItemIdentifierOpenLink" }) else {
- XCTFail("Expected Open Link item in context menu")
- return
- }
-
- XCTAssertEqual(defaultBrowserItemIndex, openLinkIndex + 1)
- let defaultBrowserItem = menu.items[defaultBrowserItemIndex]
- XCTAssertTrue(defaultBrowserItem.target === webView)
- XCTAssertNotNil(defaultBrowserItem.action)
-
- let dispatched = NSApp.sendAction(
- defaultBrowserItem.action!,
- to: defaultBrowserItem.target,
- from: defaultBrowserItem
- )
- XCTAssertTrue(dispatched)
- XCTAssertEqual(openedURL?.absoluteString, "https://example.com/docs")
- }
-
- func testWillOpenMenuSkipsDefaultBrowserItemWhenContextHasNoOpenLinkEntry() {
- let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
- let menu = NSMenu()
- menu.addItem(NSMenuItem(title: "Back", action: nil, keyEquivalent: ""))
- menu.addItem(NSMenuItem(title: "Forward", action: nil, keyEquivalent: ""))
-
- webView.willOpenMenu(menu, with: makeRightMouseDownEvent())
-
- XCTAssertFalse(menu.items.contains { $0.title == "Open Link in Default Browser" })
- }
-
- func testWillOpenMenuHooksDownloadImageToDiskMenuVariant() {
- let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
- let menu = NSMenu()
- let originalTarget = NSObject()
- let originalAction = NSSelectorFromString("downloadImageToDisk:")
- let downloadItem = NSMenuItem(title: "Download Image As...", action: originalAction, keyEquivalent: "")
- downloadItem.identifier = NSUserInterfaceItemIdentifier("WKMenuItemIdentifierDownloadImageToDisk")
- downloadItem.target = originalTarget
- menu.addItem(downloadItem)
-
- webView.willOpenMenu(menu, with: makeRightMouseDownEvent())
-
- XCTAssertTrue(downloadItem.target === webView)
- XCTAssertNotNil(downloadItem.action)
- XCTAssertNotEqual(downloadItem.action, originalAction)
- }
-
- func testWillOpenMenuHooksDownloadLinkedFileToDiskMenuVariant() {
- let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
- let menu = NSMenu()
- let originalTarget = NSObject()
- let originalAction = NSSelectorFromString("downloadLinkToDisk:")
- let downloadItem = NSMenuItem(title: "Download Linked File As...", action: originalAction, keyEquivalent: "")
- downloadItem.identifier = NSUserInterfaceItemIdentifier("WKMenuItemIdentifierDownloadLinkToDisk")
- downloadItem.target = originalTarget
- menu.addItem(downloadItem)
-
- webView.willOpenMenu(menu, with: makeRightMouseDownEvent())
-
- XCTAssertTrue(downloadItem.target === webView)
- XCTAssertNotNil(downloadItem.action)
- XCTAssertNotEqual(downloadItem.action, originalAction)
- }
-}
-
-final class BrowserDevToolsButtonDebugSettingsTests: XCTestCase {
- private func makeIsolatedDefaults() -> UserDefaults {
- let suiteName = "BrowserDevToolsButtonDebugSettingsTests.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- fatalError("Failed to create defaults suite")
- }
- defaults.removePersistentDomain(forName: suiteName)
- addTeardownBlock {
- defaults.removePersistentDomain(forName: suiteName)
- }
- return defaults
- }
-
- func testIconCatalogIncludesExpandedChoices() {
- XCTAssertGreaterThanOrEqual(BrowserDevToolsIconOption.allCases.count, 10)
- XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.terminal))
- XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.globe))
- XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.curlyBracesSquare))
- }
-
- func testIconOptionFallsBackToDefaultForUnknownRawValue() {
- let defaults = makeIsolatedDefaults()
- defaults.set("this.symbol.does.not.exist", forKey: BrowserDevToolsButtonDebugSettings.iconNameKey)
-
- XCTAssertEqual(
- BrowserDevToolsButtonDebugSettings.iconOption(defaults: defaults),
- BrowserDevToolsButtonDebugSettings.defaultIcon
- )
- }
-
- func testColorOptionFallsBackToDefaultForUnknownRawValue() {
- let defaults = makeIsolatedDefaults()
- defaults.set("notAValidColor", forKey: BrowserDevToolsButtonDebugSettings.iconColorKey)
-
- XCTAssertEqual(
- BrowserDevToolsButtonDebugSettings.colorOption(defaults: defaults),
- BrowserDevToolsButtonDebugSettings.defaultColor
- )
- }
-
- func testBrowserToolbarAccessorySpacingDefaultsToTwoWhenUnset() {
- let defaults = makeIsolatedDefaults()
- defaults.removeObject(forKey: BrowserToolbarAccessorySpacingDebugSettings.key)
-
- XCTAssertEqual(
- BrowserToolbarAccessorySpacingDebugSettings.current(defaults: defaults),
- BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing
- )
- }
-
- func testBrowserToolbarAccessorySpacingFallsBackToDefaultForUnsupportedValue() {
- let defaults = makeIsolatedDefaults()
- defaults.set(99, forKey: BrowserToolbarAccessorySpacingDebugSettings.key)
-
- XCTAssertEqual(
- BrowserToolbarAccessorySpacingDebugSettings.current(defaults: defaults),
- BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing
- )
- }
-
- func testBrowserProfilePopoverPaddingDefaultsWhenUnset() {
- let defaults = makeIsolatedDefaults()
- defaults.removeObject(forKey: BrowserProfilePopoverDebugSettings.horizontalPaddingKey)
- defaults.removeObject(forKey: BrowserProfilePopoverDebugSettings.verticalPaddingKey)
-
- XCTAssertEqual(
- BrowserProfilePopoverDebugSettings.currentHorizontalPadding(defaults: defaults),
- BrowserProfilePopoverDebugSettings.defaultHorizontalPadding
- )
- XCTAssertEqual(
- BrowserProfilePopoverDebugSettings.currentVerticalPadding(defaults: defaults),
- BrowserProfilePopoverDebugSettings.defaultVerticalPadding
- )
- }
-
- func testBrowserProfilePopoverPaddingFallsBackForUnsupportedValues() {
- let defaults = makeIsolatedDefaults()
- defaults.set(-3, forKey: BrowserProfilePopoverDebugSettings.horizontalPaddingKey)
- defaults.set(999, forKey: BrowserProfilePopoverDebugSettings.verticalPaddingKey)
-
- XCTAssertEqual(
- BrowserProfilePopoverDebugSettings.currentHorizontalPadding(defaults: defaults),
- BrowserProfilePopoverDebugSettings.defaultHorizontalPadding
- )
- XCTAssertEqual(
- BrowserProfilePopoverDebugSettings.currentVerticalPadding(defaults: defaults),
- BrowserProfilePopoverDebugSettings.defaultVerticalPadding
- )
- }
-
- func testCopyPayloadUsesPersistedValues() {
- let defaults = makeIsolatedDefaults()
- defaults.set(BrowserDevToolsIconOption.scope.rawValue, forKey: BrowserDevToolsButtonDebugSettings.iconNameKey)
- defaults.set(BrowserDevToolsIconColorOption.bonsplitActive.rawValue, forKey: BrowserDevToolsButtonDebugSettings.iconColorKey)
-
- let payload = BrowserDevToolsButtonDebugSettings.copyPayload(defaults: defaults)
- XCTAssertTrue(payload.contains("browserDevToolsIconName=scope"))
- XCTAssertTrue(payload.contains("browserDevToolsIconColor=bonsplitActive"))
- }
-}
-
-final class BrowserThemeSettingsTests: XCTestCase {
- private func makeIsolatedDefaults() -> UserDefaults {
- let suiteName = "BrowserThemeSettingsTests.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- fatalError("Failed to create defaults suite")
- }
- defaults.removePersistentDomain(forName: suiteName)
- addTeardownBlock {
- defaults.removePersistentDomain(forName: suiteName)
- }
- return defaults
- }
-
- func testDefaultsMatchConfiguredFallbacks() {
- let defaults = makeIsolatedDefaults()
- XCTAssertEqual(
- BrowserThemeSettings.mode(defaults: defaults),
- BrowserThemeSettings.defaultMode
- )
- }
-
- func testModeReadsPersistedValue() {
- let defaults = makeIsolatedDefaults()
- defaults.set(BrowserThemeMode.dark.rawValue, forKey: BrowserThemeSettings.modeKey)
- XCTAssertEqual(BrowserThemeSettings.mode(defaults: defaults), .dark)
-
- defaults.set(BrowserThemeMode.light.rawValue, forKey: BrowserThemeSettings.modeKey)
- XCTAssertEqual(BrowserThemeSettings.mode(defaults: defaults), .light)
- }
-
- func testModeMigratesLegacyForcedDarkModeFlag() {
- let defaults = makeIsolatedDefaults()
- defaults.set(true, forKey: BrowserThemeSettings.legacyForcedDarkModeEnabledKey)
- XCTAssertEqual(BrowserThemeSettings.mode(defaults: defaults), .dark)
- XCTAssertEqual(defaults.string(forKey: BrowserThemeSettings.modeKey), BrowserThemeMode.dark.rawValue)
-
- let otherDefaults = makeIsolatedDefaults()
- otherDefaults.set(false, forKey: BrowserThemeSettings.legacyForcedDarkModeEnabledKey)
- XCTAssertEqual(BrowserThemeSettings.mode(defaults: otherDefaults), .system)
- XCTAssertEqual(otherDefaults.string(forKey: BrowserThemeSettings.modeKey), BrowserThemeMode.system.rawValue)
- }
-}
-
-final class BrowserPanelChromeBackgroundColorTests: XCTestCase {
- func testLightModeUsesThemeBackgroundColor() {
- assertResolvedColorMatchesTheme(for: .light)
- }
-
- func testDarkModeUsesThemeBackgroundColor() {
- assertResolvedColorMatchesTheme(for: .dark)
- }
-
- private func assertResolvedColorMatchesTheme(
- for colorScheme: ColorScheme,
- file: StaticString = #filePath,
- line: UInt = #line
- ) {
- let themeBackground = NSColor(srgbRed: 0.13, green: 0.29, blue: 0.47, alpha: 1.0)
-
- guard
- let actual = resolvedBrowserChromeBackgroundColor(
- for: colorScheme,
- themeBackgroundColor: themeBackground
- ).usingColorSpace(.sRGB),
- let expected = themeBackground.usingColorSpace(.sRGB)
- else {
- XCTFail("Expected sRGB-convertible colors", file: file, line: line)
- return
- }
-
- XCTAssertEqual(actual.redComponent, expected.redComponent, accuracy: 0.001, file: file, line: line)
- XCTAssertEqual(actual.greenComponent, expected.greenComponent, accuracy: 0.001, file: file, line: line)
- XCTAssertEqual(actual.blueComponent, expected.blueComponent, accuracy: 0.001, file: file, line: line)
- XCTAssertEqual(actual.alphaComponent, expected.alphaComponent, accuracy: 0.001, file: file, line: line)
- }
-}
-
-final class BrowserPanelOmnibarPillBackgroundColorTests: XCTestCase {
- func testLightModeSlightlyDarkensThemeBackground() {
- assertResolvedColorMatchesExpectedBlend(for: .light, darkenMix: 0.04)
- }
-
- func testDarkModeSlightlyDarkensThemeBackground() {
- assertResolvedColorMatchesExpectedBlend(for: .dark, darkenMix: 0.05)
- }
-
- private func assertResolvedColorMatchesExpectedBlend(
- for colorScheme: ColorScheme,
- darkenMix: CGFloat,
- file: StaticString = #filePath,
- line: UInt = #line
- ) {
- let themeBackground = NSColor(srgbRed: 0.94, green: 0.93, blue: 0.91, alpha: 1.0)
- let expected = themeBackground.blended(withFraction: darkenMix, of: .black) ?? themeBackground
-
- guard
- let actual = resolvedBrowserOmnibarPillBackgroundColor(
- for: colorScheme,
- themeBackgroundColor: themeBackground
- ).usingColorSpace(.sRGB),
- let expectedSRGB = expected.usingColorSpace(.sRGB),
- let themeSRGB = themeBackground.usingColorSpace(.sRGB)
- else {
- XCTFail("Expected sRGB-convertible colors", file: file, line: line)
- return
- }
-
- XCTAssertEqual(actual.redComponent, expectedSRGB.redComponent, accuracy: 0.001, file: file, line: line)
- XCTAssertEqual(actual.greenComponent, expectedSRGB.greenComponent, accuracy: 0.001, file: file, line: line)
- XCTAssertEqual(actual.blueComponent, expectedSRGB.blueComponent, accuracy: 0.001, file: file, line: line)
- XCTAssertEqual(actual.alphaComponent, expectedSRGB.alphaComponent, accuracy: 0.001, file: file, line: line)
- XCTAssertNotEqual(actual.redComponent, themeSRGB.redComponent, file: file, line: line)
- }
-}
-
-final class SidebarActiveForegroundColorTests: XCTestCase {
- func testLightAppearanceUsesBlackWithRequestedOpacity() {
- guard let lightAppearance = NSAppearance(named: .aqua),
- let color = sidebarActiveForegroundNSColor(
- opacity: 0.8,
- appAppearance: lightAppearance
- ).usingColorSpace(.sRGB) else {
- XCTFail("Expected sRGB-convertible color")
- return
- }
-
- XCTAssertEqual(color.redComponent, 0, accuracy: 0.001)
- XCTAssertEqual(color.greenComponent, 0, accuracy: 0.001)
- XCTAssertEqual(color.blueComponent, 0, accuracy: 0.001)
- XCTAssertEqual(color.alphaComponent, 0.8, accuracy: 0.001)
- }
-
- func testDarkAppearanceUsesWhiteWithRequestedOpacity() {
- guard let darkAppearance = NSAppearance(named: .darkAqua),
- let color = sidebarActiveForegroundNSColor(
- opacity: 0.65,
- appAppearance: darkAppearance
- ).usingColorSpace(.sRGB) else {
- XCTFail("Expected sRGB-convertible color")
- return
- }
-
- XCTAssertEqual(color.redComponent, 1, accuracy: 0.001)
- XCTAssertEqual(color.greenComponent, 1, accuracy: 0.001)
- XCTAssertEqual(color.blueComponent, 1, accuracy: 0.001)
- XCTAssertEqual(color.alphaComponent, 0.65, accuracy: 0.001)
- }
-}
-
-final class SidebarSelectedWorkspaceColorTests: XCTestCase {
- func testLightModeUsesConfiguredSelectedWorkspaceBackgroundColor() {
- guard let color = sidebarSelectedWorkspaceBackgroundNSColor(for: .light).usingColorSpace(.sRGB) else {
- XCTFail("Expected sRGB-convertible color")
- return
- }
-
- XCTAssertEqual(color.redComponent, 0, accuracy: 0.001)
- XCTAssertEqual(color.greenComponent, 136.0 / 255.0, accuracy: 0.001)
- XCTAssertEqual(color.blueComponent, 1.0, accuracy: 0.001)
- XCTAssertEqual(color.alphaComponent, 1.0, accuracy: 0.001)
- }
-
- func testDarkModeUsesConfiguredSelectedWorkspaceBackgroundColor() {
- guard let color = sidebarSelectedWorkspaceBackgroundNSColor(for: .dark).usingColorSpace(.sRGB) else {
- XCTFail("Expected sRGB-convertible color")
- return
- }
-
- XCTAssertEqual(color.redComponent, 0, accuracy: 0.001)
- XCTAssertEqual(color.greenComponent, 145.0 / 255.0, accuracy: 0.001)
- XCTAssertEqual(color.blueComponent, 1.0, accuracy: 0.001)
- XCTAssertEqual(color.alphaComponent, 1.0, accuracy: 0.001)
- }
-
- func testSelectedWorkspaceForegroundAlwaysUsesWhiteWithRequestedOpacity() {
- guard let color = sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.65).usingColorSpace(.sRGB) else {
- XCTFail("Expected sRGB-convertible color")
- return
- }
-
- XCTAssertEqual(color.redComponent, 1.0, accuracy: 0.001)
- XCTAssertEqual(color.greenComponent, 1.0, accuracy: 0.001)
- XCTAssertEqual(color.blueComponent, 1.0, accuracy: 0.001)
- XCTAssertEqual(color.alphaComponent, 0.65, accuracy: 0.001)
- }
-}
-final class BrowserDeveloperToolsShortcutDefaultsTests: XCTestCase {
- func testSafariDefaultShortcutForToggleDeveloperTools() {
- let shortcut = KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut
- XCTAssertEqual(shortcut.key, "i")
- XCTAssertTrue(shortcut.command)
- XCTAssertTrue(shortcut.option)
- XCTAssertFalse(shortcut.shift)
- XCTAssertFalse(shortcut.control)
- }
-
- func testSafariDefaultShortcutForShowJavaScriptConsole() {
- let shortcut = KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultShortcut
- XCTAssertEqual(shortcut.key, "c")
- XCTAssertTrue(shortcut.command)
- XCTAssertTrue(shortcut.option)
- XCTAssertFalse(shortcut.shift)
- XCTAssertFalse(shortcut.control)
- }
-}
-
-final class WorkspaceRenameShortcutDefaultsTests: XCTestCase {
- func testRenameTabShortcutDefaultsAndMetadata() {
- XCTAssertEqual(KeyboardShortcutSettings.Action.renameTab.label, "Rename Tab")
- XCTAssertEqual(KeyboardShortcutSettings.Action.renameTab.defaultsKey, "shortcut.renameTab")
-
- let shortcut = KeyboardShortcutSettings.Action.renameTab.defaultShortcut
- XCTAssertEqual(shortcut.key, "r")
- XCTAssertTrue(shortcut.command)
- XCTAssertFalse(shortcut.shift)
- XCTAssertFalse(shortcut.option)
- XCTAssertFalse(shortcut.control)
- }
-
- func testCloseWindowShortcutDefaultsAndMetadata() {
- XCTAssertEqual(KeyboardShortcutSettings.Action.closeWindow.label, "Close Window")
- XCTAssertEqual(KeyboardShortcutSettings.Action.closeWindow.defaultsKey, "shortcut.closeWindow")
-
- let shortcut = KeyboardShortcutSettings.Action.closeWindow.defaultShortcut
- XCTAssertEqual(shortcut.key, "w")
- XCTAssertTrue(shortcut.command)
- XCTAssertFalse(shortcut.shift)
- XCTAssertFalse(shortcut.option)
- XCTAssertTrue(shortcut.control)
- }
-
- func testRenameWorkspaceShortcutDefaultsAndMetadata() {
- XCTAssertEqual(KeyboardShortcutSettings.Action.renameWorkspace.label, "Rename Workspace")
- XCTAssertEqual(KeyboardShortcutSettings.Action.renameWorkspace.defaultsKey, "shortcut.renameWorkspace")
-
- let shortcut = KeyboardShortcutSettings.Action.renameWorkspace.defaultShortcut
- XCTAssertEqual(shortcut.key, "r")
- XCTAssertTrue(shortcut.command)
- XCTAssertTrue(shortcut.shift)
- XCTAssertFalse(shortcut.option)
- XCTAssertFalse(shortcut.control)
- }
-
- func testRenameWorkspaceShortcutConvertsToMenuShortcut() {
- let shortcut = KeyboardShortcutSettings.Action.renameWorkspace.defaultShortcut
- XCTAssertNotNil(shortcut.keyEquivalent)
- XCTAssertTrue(shortcut.eventModifiers.contains(.command))
- XCTAssertTrue(shortcut.eventModifiers.contains(.shift))
- XCTAssertFalse(shortcut.eventModifiers.contains(.option))
- XCTAssertFalse(shortcut.eventModifiers.contains(.control))
- }
-
- func testCloseWorkspaceShortcutDefaultsAndMetadata() {
- XCTAssertEqual(KeyboardShortcutSettings.Action.closeWorkspace.label, "Close Workspace")
- XCTAssertEqual(KeyboardShortcutSettings.Action.closeWorkspace.defaultsKey, "shortcut.closeWorkspace")
-
- let shortcut = KeyboardShortcutSettings.Action.closeWorkspace.defaultShortcut
- XCTAssertEqual(shortcut.key, "w")
- XCTAssertTrue(shortcut.command)
- XCTAssertTrue(shortcut.shift)
- XCTAssertFalse(shortcut.option)
- XCTAssertFalse(shortcut.control)
- }
-
- func testCloseWorkspaceShortcutConvertsToMenuShortcut() {
- let shortcut = KeyboardShortcutSettings.Action.closeWorkspace.defaultShortcut
- XCTAssertNotNil(shortcut.keyEquivalent)
- XCTAssertTrue(shortcut.eventModifiers.contains(.command))
- XCTAssertTrue(shortcut.eventModifiers.contains(.shift))
- XCTAssertFalse(shortcut.eventModifiers.contains(.option))
- XCTAssertFalse(shortcut.eventModifiers.contains(.control))
- }
-
- func testNextPreviousWorkspaceShortcutDefaultsAndMetadata() {
- XCTAssertEqual(KeyboardShortcutSettings.Action.nextSidebarTab.label, "Next Workspace")
- XCTAssertEqual(KeyboardShortcutSettings.Action.prevSidebarTab.label, "Previous Workspace")
- XCTAssertEqual(KeyboardShortcutSettings.Action.nextSidebarTab.defaultsKey, "shortcut.nextSidebarTab")
- XCTAssertEqual(KeyboardShortcutSettings.Action.prevSidebarTab.defaultsKey, "shortcut.prevSidebarTab")
-
- let nextShortcut = KeyboardShortcutSettings.Action.nextSidebarTab.defaultShortcut
- XCTAssertEqual(nextShortcut.key, "]")
- XCTAssertTrue(nextShortcut.command)
- XCTAssertFalse(nextShortcut.shift)
- XCTAssertFalse(nextShortcut.option)
- XCTAssertTrue(nextShortcut.control)
-
- let prevShortcut = KeyboardShortcutSettings.Action.prevSidebarTab.defaultShortcut
- XCTAssertEqual(prevShortcut.key, "[")
- XCTAssertTrue(prevShortcut.command)
- XCTAssertFalse(prevShortcut.shift)
- XCTAssertFalse(prevShortcut.option)
- XCTAssertTrue(prevShortcut.control)
- }
-
- func testNextPreviousWorkspaceShortcutsConvertToMenuShortcut() {
- let nextShortcut = KeyboardShortcutSettings.Action.nextSidebarTab.defaultShortcut
- XCTAssertNotNil(nextShortcut.keyEquivalent)
- XCTAssertEqual(nextShortcut.menuItemKeyEquivalent, "]")
- XCTAssertTrue(nextShortcut.eventModifiers.contains(.command))
- XCTAssertTrue(nextShortcut.eventModifiers.contains(.control))
-
- let prevShortcut = KeyboardShortcutSettings.Action.prevSidebarTab.defaultShortcut
- XCTAssertNotNil(prevShortcut.keyEquivalent)
- XCTAssertEqual(prevShortcut.menuItemKeyEquivalent, "[")
- XCTAssertTrue(prevShortcut.eventModifiers.contains(.command))
- XCTAssertTrue(prevShortcut.eventModifiers.contains(.control))
- }
-
- func testToggleTerminalCopyModeShortcutDefaultsAndMetadata() {
- XCTAssertEqual(KeyboardShortcutSettings.Action.toggleTerminalCopyMode.label, "Toggle Terminal Copy Mode")
- XCTAssertEqual(
- KeyboardShortcutSettings.Action.toggleTerminalCopyMode.defaultsKey,
- "shortcut.toggleTerminalCopyMode"
- )
-
- let shortcut = KeyboardShortcutSettings.Action.toggleTerminalCopyMode.defaultShortcut
- XCTAssertEqual(shortcut.key, "m")
- XCTAssertTrue(shortcut.command)
- XCTAssertTrue(shortcut.shift)
- XCTAssertFalse(shortcut.option)
- XCTAssertFalse(shortcut.control)
- }
-
- func testMenuItemKeyEquivalentHandlesArrowAndTabKeys() {
- XCTAssertNotNil(StoredShortcut(key: "←", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent)
- XCTAssertNotNil(StoredShortcut(key: "→", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent)
- XCTAssertNotNil(StoredShortcut(key: "↑", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent)
- XCTAssertNotNil(StoredShortcut(key: "↓", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent)
- XCTAssertEqual(
- StoredShortcut(key: "\t", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent,
- "\t"
- )
- }
-
- func testShortcutDefaultsKeysRemainUnique() {
- let keys = KeyboardShortcutSettings.Action.allCases.map(\.defaultsKey)
- XCTAssertEqual(Set(keys).count, keys.count)
- }
-}
-
-final class TerminalKeyboardCopyModeActionTests: XCTestCase {
- func testCopyModeBypassAllowsOnlyCommandShortcuts() {
- XCTAssertTrue(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.command]))
- XCTAssertTrue(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.command, .shift]))
- XCTAssertTrue(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.command, .option]))
- XCTAssertFalse(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.option]))
- XCTAssertFalse(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.option, .shift]))
- XCTAssertFalse(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.control]))
- }
-
- func testJKWithoutSelectionScrollByLine() {
- XCTAssertEqual(
- terminalKeyboardCopyModeAction(
- keyCode: 38,
- charactersIgnoringModifiers: "j",
- modifierFlags: [],
- hasSelection: false
- ),
- .scrollLines(1)
- )
- XCTAssertEqual(
- terminalKeyboardCopyModeAction(
- keyCode: 40,
- charactersIgnoringModifiers: "k",
- modifierFlags: [],
- hasSelection: false
- ),
- .scrollLines(-1)
- )
- }
-
- func testCapsLockDoesNotBlockLetterMappings() {
- XCTAssertEqual(
- terminalKeyboardCopyModeAction(
- keyCode: 38,
- charactersIgnoringModifiers: "j",
- modifierFlags: [.capsLock],
- hasSelection: false
- ),
- .scrollLines(1)
- )
- }
-
- func testJKWithSelectionAdjustSelection() {
- XCTAssertEqual(
- terminalKeyboardCopyModeAction(
- keyCode: 38,
- charactersIgnoringModifiers: "j",
- modifierFlags: [],
- hasSelection: true
- ),
- .adjustSelection(.down)
- )
- XCTAssertEqual(
- terminalKeyboardCopyModeAction(
- keyCode: 40,
- charactersIgnoringModifiers: "k",
- modifierFlags: [],
- hasSelection: true
- ),
- .adjustSelection(.up)
- )
- }
-
- func testControlPagingSupportsPrintableAndControlCharacters() {
- // Ctrl+U = half-page up (vim standard).
- XCTAssertEqual(
- terminalKeyboardCopyModeAction(
- keyCode: 0,
- charactersIgnoringModifiers: "\u{15}",
- modifierFlags: [.control],
- hasSelection: false
- ),
- .scrollHalfPage(-1)
- )
- XCTAssertEqual(
- terminalKeyboardCopyModeAction(
- keyCode: 0,
- charactersIgnoringModifiers: "\u{04}",
- modifierFlags: [.control],
- hasSelection: true
- ),
- .adjustSelection(.pageDown)
- )
- XCTAssertEqual(
- terminalKeyboardCopyModeAction(
- keyCode: 0,
- charactersIgnoringModifiers: "\u{02}",
- modifierFlags: [.control],
- hasSelection: false
- ),
- .scrollPage(-1)
- )
- XCTAssertEqual(
- terminalKeyboardCopyModeAction(
- keyCode: 0,
- charactersIgnoringModifiers: "\u{06}",
- modifierFlags: [.control],
- hasSelection: true
- ),
- .adjustSelection(.pageDown)
- )
- XCTAssertEqual(
- terminalKeyboardCopyModeAction(
- keyCode: 0,
- charactersIgnoringModifiers: "\u{19}",
- modifierFlags: [.control],
- hasSelection: false
- ),
- .scrollLines(-1)
- )
- XCTAssertEqual(
- terminalKeyboardCopyModeAction(
- keyCode: 0,
- charactersIgnoringModifiers: "\u{05}",
- modifierFlags: [.control],
- hasSelection: true
- ),
- .adjustSelection(.down)
- )
- }
-
- func testVGYMapping() {
- XCTAssertEqual(
- terminalKeyboardCopyModeAction(
- keyCode: 9,
- charactersIgnoringModifiers: "v",
- modifierFlags: [],
- hasSelection: false
- ),
- .startSelection
- )
- XCTAssertEqual(
- terminalKeyboardCopyModeAction(
- keyCode: 9,
- charactersIgnoringModifiers: "v",
- modifierFlags: [],
- hasSelection: true
- ),
- .clearSelection
- )
- XCTAssertEqual(
- terminalKeyboardCopyModeAction(
- keyCode: 16,
- charactersIgnoringModifiers: "y",
- modifierFlags: [],
- hasSelection: true
- ),
- .copyAndExit
- )
- }
-
- func testGAndShiftGMapping() {
- // Bare "g" is a prefix key (gg), not an immediate action.
- XCTAssertNil(
- terminalKeyboardCopyModeAction(
- keyCode: 5,
- charactersIgnoringModifiers: "g",
- modifierFlags: [],
- hasSelection: false
- )
- )
- XCTAssertEqual(
- terminalKeyboardCopyModeAction(
- keyCode: 5,
- charactersIgnoringModifiers: "g",
- modifierFlags: [.shift],
- hasSelection: false
- ),
- .scrollToBottom
- )
- }
-
- func testLineBoundaryPromptAndSearchMappings() {
- XCTAssertEqual(
- terminalKeyboardCopyModeAction(
- keyCode: 29,
- charactersIgnoringModifiers: "0",
- modifierFlags: [],
- hasSelection: true
- ),
- .adjustSelection(.beginningOfLine)
- )
- XCTAssertEqual(
- terminalKeyboardCopyModeAction(
- keyCode: 20,
- charactersIgnoringModifiers: "^",
- modifierFlags: [.shift],
- hasSelection: true
- ),
- .adjustSelection(.beginningOfLine)
- )
- XCTAssertEqual(
- terminalKeyboardCopyModeAction(
- keyCode: 21,
- charactersIgnoringModifiers: "4",
- modifierFlags: [.shift],
- hasSelection: true
- ),
- .adjustSelection(.endOfLine)
- )
- XCTAssertEqual(
- terminalKeyboardCopyModeAction(
- keyCode: 33,
- charactersIgnoringModifiers: "[",
- modifierFlags: [.shift],
- hasSelection: false
- ),
- .jumpToPrompt(-1)
- )
- XCTAssertEqual(
- terminalKeyboardCopyModeAction(
- keyCode: 30,
- charactersIgnoringModifiers: "]",
- modifierFlags: [.shift],
- hasSelection: false
- ),
- .jumpToPrompt(1)
- )
- XCTAssertNil(
- terminalKeyboardCopyModeAction(
- keyCode: 21,
- charactersIgnoringModifiers: "4",
- modifierFlags: [],
- hasSelection: true
- )
- )
- XCTAssertNil(
- terminalKeyboardCopyModeAction(
- keyCode: 33,
- charactersIgnoringModifiers: "[",
- modifierFlags: [],
- hasSelection: false
- )
- )
- XCTAssertNil(
- terminalKeyboardCopyModeAction(
- keyCode: 30,
- charactersIgnoringModifiers: "]",
- modifierFlags: [],
- hasSelection: false
- )
- )
- XCTAssertEqual(
- terminalKeyboardCopyModeAction(
- keyCode: 44,
- charactersIgnoringModifiers: "/",
- modifierFlags: [],
- hasSelection: false
- ),
- .startSearch
- )
- XCTAssertEqual(
- terminalKeyboardCopyModeAction(
- keyCode: 45,
- charactersIgnoringModifiers: "n",
- modifierFlags: [],
- hasSelection: false
- ),
- .searchNext
- )
- XCTAssertEqual(
- terminalKeyboardCopyModeAction(
- keyCode: 45,
- charactersIgnoringModifiers: "n",
- modifierFlags: [.shift],
- hasSelection: false
- ),
- .searchPrevious
- )
- }
-
- func testShiftVMatchesVisualToggleBehavior() {
- XCTAssertEqual(
- terminalKeyboardCopyModeAction(
- keyCode: 9,
- charactersIgnoringModifiers: "v",
- modifierFlags: [.shift],
- hasSelection: false
- ),
- .startSelection
- )
- XCTAssertEqual(
- terminalKeyboardCopyModeAction(
- keyCode: 9,
- charactersIgnoringModifiers: "v",
- modifierFlags: [.shift],
- hasSelection: true
- ),
- .clearSelection
- )
- }
-
- func testEscapeAlwaysExits() {
- XCTAssertEqual(
- terminalKeyboardCopyModeAction(
- keyCode: 53,
- charactersIgnoringModifiers: "",
- modifierFlags: [],
- hasSelection: false
- ),
- .exit
- )
- }
-
- func testQAlwaysExits() {
- XCTAssertEqual(
- terminalKeyboardCopyModeAction(
- keyCode: 12, // kVK_ANSI_Q
- charactersIgnoringModifiers: "q",
- modifierFlags: [],
- hasSelection: false
- ),
- .exit
- )
- }
-}
-
-final class TerminalKeyboardCopyModeResolveTests: XCTestCase {
- private func resolve(
- _ keyCode: UInt16,
- chars: String,
- modifiers: NSEvent.ModifierFlags = [],
- hasSelection: Bool,
- state: inout TerminalKeyboardCopyModeInputState
- ) -> TerminalKeyboardCopyModeResolution {
- terminalKeyboardCopyModeResolve(
- keyCode: keyCode,
- charactersIgnoringModifiers: chars,
- modifierFlags: modifiers,
- hasSelection: hasSelection,
- state: &state
- )
- }
-
- func testCountPrefixAppliesToMotion() {
- var state = TerminalKeyboardCopyModeInputState()
- XCTAssertEqual(resolve(20, chars: "3", hasSelection: false, state: &state), .consume)
- XCTAssertEqual(resolve(38, chars: "j", hasSelection: false, state: &state), .perform(.scrollLines(1), count: 3))
- XCTAssertEqual(state, TerminalKeyboardCopyModeInputState())
- }
-
- func testZeroAppendsCountOrActsAsMotion() {
- var state = TerminalKeyboardCopyModeInputState()
- XCTAssertEqual(resolve(19, chars: "2", hasSelection: false, state: &state), .consume)
- XCTAssertEqual(resolve(29, chars: "0", hasSelection: false, state: &state), .consume)
- XCTAssertEqual(resolve(40, chars: "k", hasSelection: false, state: &state), .perform(.scrollLines(-1), count: 20))
-
- var selectionState = TerminalKeyboardCopyModeInputState()
- XCTAssertEqual(
- resolve(29, chars: "0", hasSelection: true, state: &selectionState),
- .perform(.adjustSelection(.beginningOfLine), count: 1)
- )
- }
-
- func testYankLineOperatorSupportsYYAndYWithCounts() {
- var yyState = TerminalKeyboardCopyModeInputState()
- XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &yyState), .consume)
- XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &yyState), .perform(.copyLineAndExit, count: 1))
-
- var countedState = TerminalKeyboardCopyModeInputState()
- XCTAssertEqual(resolve(21, chars: "4", hasSelection: false, state: &countedState), .consume)
- XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &countedState), .consume)
- XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &countedState), .perform(.copyLineAndExit, count: 4))
-
- var shiftYState = TerminalKeyboardCopyModeInputState()
- XCTAssertEqual(resolve(20, chars: "3", hasSelection: false, state: &shiftYState), .consume)
- XCTAssertEqual(
- resolve(16, chars: "y", modifiers: [.shift], hasSelection: false, state: &shiftYState),
- .perform(.copyLineAndExit, count: 3)
- )
- }
-
- func testPendingYankLineDoesNotSwallowNextCommand() {
- var state = TerminalKeyboardCopyModeInputState()
- XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &state), .consume)
- XCTAssertEqual(resolve(38, chars: "j", hasSelection: false, state: &state), .perform(.scrollLines(1), count: 1))
- XCTAssertEqual(state, TerminalKeyboardCopyModeInputState())
- }
-
- func testSearchAndPromptMotionsUseCounts() {
- var promptState = TerminalKeyboardCopyModeInputState()
- XCTAssertEqual(resolve(20, chars: "3", hasSelection: false, state: &promptState), .consume)
- XCTAssertEqual(
- resolve(30, chars: "]", modifiers: [.shift], hasSelection: false, state: &promptState),
- .perform(.jumpToPrompt(1), count: 3)
- )
-
- var searchState = TerminalKeyboardCopyModeInputState()
- XCTAssertEqual(resolve(18, chars: "2", hasSelection: false, state: &searchState), .consume)
- XCTAssertEqual(resolve(45, chars: "n", hasSelection: false, state: &searchState), .perform(.searchNext, count: 2))
- }
-
- func testInvalidKeyClearsPendingState() {
- var state = TerminalKeyboardCopyModeInputState()
- XCTAssertEqual(resolve(18, chars: "2", hasSelection: false, state: &state), .consume)
- XCTAssertEqual(resolve(7, chars: "x", hasSelection: false, state: &state), .consume)
- XCTAssertEqual(state, TerminalKeyboardCopyModeInputState())
- }
-
- // MARK: - gg (scroll to top via two-key sequence)
-
- func testGGScrollsToTop() {
- var state = TerminalKeyboardCopyModeInputState()
- XCTAssertEqual(resolve(5, chars: "g", hasSelection: false, state: &state), .consume)
- XCTAssertEqual(resolve(5, chars: "g", hasSelection: false, state: &state), .perform(.scrollToTop, count: 1))
- XCTAssertEqual(state, TerminalKeyboardCopyModeInputState())
- }
-
- func testGGWithSelectionAdjustsToHome() {
- var state = TerminalKeyboardCopyModeInputState()
- XCTAssertEqual(resolve(5, chars: "g", hasSelection: true, state: &state), .consume)
- XCTAssertEqual(resolve(5, chars: "g", hasSelection: true, state: &state), .perform(.adjustSelection(.home), count: 1))
- XCTAssertEqual(state, TerminalKeyboardCopyModeInputState())
- }
-
- func testCountedGG() {
- var state = TerminalKeyboardCopyModeInputState()
- XCTAssertEqual(resolve(22, chars: "5", hasSelection: false, state: &state), .consume)
- XCTAssertEqual(resolve(5, chars: "g", hasSelection: false, state: &state), .consume)
- XCTAssertEqual(resolve(5, chars: "g", hasSelection: false, state: &state), .perform(.scrollToTop, count: 5))
- }
-
- func testPendingGCancelledByOtherKey() {
- var state = TerminalKeyboardCopyModeInputState()
- XCTAssertEqual(resolve(5, chars: "g", hasSelection: false, state: &state), .consume)
- XCTAssertEqual(resolve(38, chars: "j", hasSelection: false, state: &state), .perform(.scrollLines(1), count: 1))
- XCTAssertEqual(state, TerminalKeyboardCopyModeInputState())
- }
-
- func testShiftGStillWorksImmediately() {
- var state = TerminalKeyboardCopyModeInputState()
- XCTAssertEqual(
- resolve(5, chars: "g", modifiers: [.shift], hasSelection: false, state: &state),
- .perform(.scrollToBottom, count: 1)
- )
- XCTAssertEqual(state, TerminalKeyboardCopyModeInputState())
- }
-
- // MARK: - Ctrl+U/D half-page scroll
-
- func testCtrlUHalfPage() {
- var state = TerminalKeyboardCopyModeInputState()
- XCTAssertEqual(
- resolve(32, chars: "u", modifiers: [.control], hasSelection: false, state: &state),
- .perform(.scrollHalfPage(-1), count: 1)
- )
- }
-
- func testCtrlDHalfPage() {
- var state = TerminalKeyboardCopyModeInputState()
- XCTAssertEqual(
- resolve(2, chars: "d", modifiers: [.control], hasSelection: false, state: &state),
- .perform(.scrollHalfPage(1), count: 1)
- )
- }
-
- func testCtrlBFullPage() {
- var state = TerminalKeyboardCopyModeInputState()
- XCTAssertEqual(
- resolve(11, chars: "b", modifiers: [.control], hasSelection: false, state: &state),
- .perform(.scrollPage(-1), count: 1)
- )
- }
-
- func testCtrlFFullPage() {
- var state = TerminalKeyboardCopyModeInputState()
- XCTAssertEqual(
- resolve(3, chars: "f", modifiers: [.control], hasSelection: false, state: &state),
- .perform(.scrollPage(1), count: 1)
- )
- }
-}
-
-final class TerminalKeyboardCopyModeViewportRowTests: XCTestCase {
- func testInitialViewportRowUsesImePointBaseline() {
- XCTAssertEqual(
- terminalKeyboardCopyModeInitialViewportRow(
- rows: 24,
- imePointY: 24,
- imeCellHeight: 24
- ),
- 0
- )
- XCTAssertEqual(
- terminalKeyboardCopyModeInitialViewportRow(
- rows: 24,
- imePointY: 240,
- imeCellHeight: 24
- ),
- 9
- )
- XCTAssertEqual(
- terminalKeyboardCopyModeInitialViewportRow(
- rows: 24,
- imePointY: 48,
- imeCellHeight: 24,
- topPadding: 24
- ),
- 0
- )
- }
-
- func testInitialViewportRowClampsBoundsAndFallsBackWhenHeightMissing() {
- XCTAssertEqual(
- terminalKeyboardCopyModeInitialViewportRow(
- rows: 24,
- imePointY: 0,
- imeCellHeight: 24
- ),
- 0
- )
- XCTAssertEqual(
- terminalKeyboardCopyModeInitialViewportRow(
- rows: 24,
- imePointY: 9999,
- imeCellHeight: 24
- ),
- 23
- )
- XCTAssertEqual(
- terminalKeyboardCopyModeInitialViewportRow(
- rows: 24,
- imePointY: 123,
- imeCellHeight: 0
- ),
- 23
- )
- }
-}
-
-@MainActor
-final class BrowserDeveloperToolsConfigurationTests: XCTestCase {
- func testBrowserPanelEnablesInspectableWebViewAndDeveloperExtras() {
- let panel = BrowserPanel(workspaceId: UUID())
- let developerExtras = panel.webView.configuration.preferences.value(forKey: "developerExtrasEnabled") as? Bool
- XCTAssertEqual(developerExtras, true)
-
- if #available(macOS 13.3, *) {
- XCTAssertTrue(panel.webView.isInspectable)
- }
- }
-
- func testBrowserPanelRefreshesUnderPageBackgroundColorWhenGhosttyBackgroundChanges() {
- let panel = BrowserPanel(workspaceId: UUID())
- let updatedColor = NSColor(srgbRed: 0.18, green: 0.29, blue: 0.44, alpha: 1.0)
- let updatedOpacity = 0.57
-
- NotificationCenter.default.post(
- name: .ghosttyDefaultBackgroundDidChange,
- object: nil,
- userInfo: [
- GhosttyNotificationKey.backgroundColor: updatedColor,
- GhosttyNotificationKey.backgroundOpacity: updatedOpacity
- ]
- )
-
- guard let actual = panel.webView.underPageBackgroundColor?.usingColorSpace(.sRGB),
- let expected = updatedColor.withAlphaComponent(updatedOpacity).usingColorSpace(.sRGB) else {
- XCTFail("Expected sRGB-convertible under-page background colors")
- return
- }
-
- XCTAssertEqual(actual.redComponent, expected.redComponent, accuracy: 0.005)
- XCTAssertEqual(actual.greenComponent, expected.greenComponent, accuracy: 0.005)
- XCTAssertEqual(actual.blueComponent, expected.blueComponent, accuracy: 0.005)
- XCTAssertEqual(actual.alphaComponent, expected.alphaComponent, accuracy: 0.005)
- }
-
- func testBrowserPanelStartsAsNewTabWithoutLoadingAboutBlank() {
- let panel = BrowserPanel(workspaceId: UUID())
-
- XCTAssertEqual(panel.displayTitle, "New tab")
- XCTAssertFalse(panel.shouldRenderWebView)
- XCTAssertTrue(panel.isShowingNewTabPage)
- XCTAssertNil(panel.webView.url)
- XCTAssertNil(panel.currentURL)
- }
-
- func testBrowserPanelLeavesNewTabPageStateWhenNavigationStarts() {
- let panel = BrowserPanel(workspaceId: UUID())
-
- XCTAssertTrue(panel.isShowingNewTabPage)
- panel.navigate(to: URL(string: "https://example.com")!)
- XCTAssertFalse(panel.isShowingNewTabPage)
- }
-
- func testBrowserPanelThemeModeUpdatesWebViewAppearance() {
- let panel = BrowserPanel(workspaceId: UUID())
-
- panel.setBrowserThemeMode(.dark)
- XCTAssertEqual(panel.webView.appearance?.bestMatch(from: [.darkAqua, .aqua]), .darkAqua)
-
- panel.setBrowserThemeMode(.light)
- XCTAssertEqual(panel.webView.appearance?.bestMatch(from: [.aqua, .darkAqua]), .aqua)
-
- panel.setBrowserThemeMode(.system)
- XCTAssertNil(panel.webView.appearance)
- }
-
- func testBrowserPanelRefreshesUnderPageBackgroundColorWithGhosttyOpacity() {
- let panel = BrowserPanel(workspaceId: UUID())
- let updatedColor = NSColor(srgbRed: 0.18, green: 0.29, blue: 0.44, alpha: 1.0)
-
- NotificationCenter.default.post(
- name: .ghosttyDefaultBackgroundDidChange,
- object: nil,
- userInfo: [
- GhosttyNotificationKey.backgroundColor: updatedColor,
- GhosttyNotificationKey.backgroundOpacity: NSNumber(value: 0.57),
- ]
- )
-
- guard let actual = panel.webView.underPageBackgroundColor?.usingColorSpace(.sRGB),
- let expected = updatedColor.withAlphaComponent(0.57).usingColorSpace(.sRGB) else {
- XCTFail("Expected sRGB-convertible under-page background colors")
- return
- }
-
- XCTAssertEqual(actual.redComponent, expected.redComponent, accuracy: 0.005)
- XCTAssertEqual(actual.greenComponent, expected.greenComponent, accuracy: 0.005)
- XCTAssertEqual(actual.blueComponent, expected.blueComponent, accuracy: 0.005)
- XCTAssertEqual(actual.alphaComponent, expected.alphaComponent, accuracy: 0.005)
- }
-}
-
-final class GhosttyBackgroundThemeTests: XCTestCase {
- func testColorClampsOpacity() {
- let base = NSColor(srgbRed: 0.10, green: 0.20, blue: 0.30, alpha: 1.0)
-
- let lowerClamped = GhosttyBackgroundTheme.color(backgroundColor: base, opacity: -2.0)
- XCTAssertEqual(lowerClamped.alphaComponent, 0.0, accuracy: 0.0001)
-
- let upperClamped = GhosttyBackgroundTheme.color(backgroundColor: base, opacity: 5.0)
- XCTAssertEqual(upperClamped.alphaComponent, 1.0, accuracy: 0.0001)
- }
-
- func testColorFromNotificationUsesBackgroundAndOpacity() {
- let fallbackColor = NSColor.black
- let fallbackOpacity = 1.0
- let notification = Notification(
- name: .ghosttyDefaultBackgroundDidChange,
- object: nil,
- userInfo: [
- GhosttyNotificationKey.backgroundColor: NSColor(srgbRed: 0.18, green: 0.29, blue: 0.44, alpha: 1.0),
- GhosttyNotificationKey.backgroundOpacity: NSNumber(value: 0.57),
- ]
- )
-
- let actual = GhosttyBackgroundTheme.color(
- from: notification,
- fallbackColor: fallbackColor,
- fallbackOpacity: fallbackOpacity
- )
- guard let srgb = actual.usingColorSpace(.sRGB) else {
- XCTFail("Expected sRGB-convertible color")
- return
- }
-
- XCTAssertEqual(srgb.redComponent, 0.18, accuracy: 0.005)
- XCTAssertEqual(srgb.greenComponent, 0.29, accuracy: 0.005)
- XCTAssertEqual(srgb.blueComponent, 0.44, accuracy: 0.005)
- XCTAssertEqual(srgb.alphaComponent, 0.57, accuracy: 0.005)
- }
-
- func testColorFromNotificationFallsBackWhenPayloadMissing() {
- let fallbackColor = NSColor(srgbRed: 0.12, green: 0.34, blue: 0.56, alpha: 1.0)
- let fallbackOpacity = 0.42
- let notification = Notification(name: .ghosttyDefaultBackgroundDidChange)
-
- let actual = GhosttyBackgroundTheme.color(
- from: notification,
- fallbackColor: fallbackColor,
- fallbackOpacity: fallbackOpacity
- )
- guard let srgb = actual.usingColorSpace(.sRGB) else {
- XCTFail("Expected sRGB-convertible color")
- return
- }
-
- XCTAssertEqual(srgb.redComponent, 0.12, accuracy: 0.005)
- XCTAssertEqual(srgb.greenComponent, 0.34, accuracy: 0.005)
- XCTAssertEqual(srgb.blueComponent, 0.56, accuracy: 0.005)
- XCTAssertEqual(srgb.alphaComponent, 0.42, accuracy: 0.005)
- }
-}
-
-@MainActor
-final class BrowserInsecureHTTPAlertPresentationTests: XCTestCase {
- private final class BrowserInsecureHTTPAlertSpy: NSAlert {
- private(set) var beginSheetModalCallCount = 0
- private(set) var runModalCallCount = 0
- var nextResponse: NSApplication.ModalResponse = .alertThirdButtonReturn
-
- override func beginSheetModal(
- for sheetWindow: NSWindow,
- completionHandler handler: ((NSApplication.ModalResponse) -> Void)?
- ) {
- beginSheetModalCallCount += 1
- handler?(nextResponse)
- }
-
- override func runModal() -> NSApplication.ModalResponse {
- runModalCallCount += 1
- return nextResponse
- }
- }
-
- func testInsecureHTTPPromptUsesSheetWhenWindowIsAvailable() {
- let panel = BrowserPanel(workspaceId: UUID())
- defer { panel.resetInsecureHTTPAlertHooksForTesting() }
-
- let alertSpy = BrowserInsecureHTTPAlertSpy()
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 480, height: 320),
- styleMask: [.titled],
- backing: .buffered,
- defer: false
- )
-
- panel.configureInsecureHTTPAlertHooksForTesting(
- alertFactory: { alertSpy },
- windowProvider: { window }
- )
- panel.presentInsecureHTTPAlertForTesting(url: URL(string: "http://example.com")!)
-
- XCTAssertEqual(alertSpy.beginSheetModalCallCount, 1)
- XCTAssertEqual(alertSpy.runModalCallCount, 0)
- }
-
- func testInsecureHTTPPromptFallsBackToRunModalWithoutWindow() {
- let panel = BrowserPanel(workspaceId: UUID())
- defer { panel.resetInsecureHTTPAlertHooksForTesting() }
-
- let alertSpy = BrowserInsecureHTTPAlertSpy()
- panel.configureInsecureHTTPAlertHooksForTesting(
- alertFactory: { alertSpy },
- windowProvider: { nil }
- )
- panel.presentInsecureHTTPAlertForTesting(url: URL(string: "http://example.com")!)
-
- XCTAssertEqual(alertSpy.beginSheetModalCallCount, 0)
- XCTAssertEqual(alertSpy.runModalCallCount, 1)
- }
-}
-
-final class BrowserNavigationNewTabDecisionTests: XCTestCase {
- func testLinkActivatedCmdClickOpensInNewTab() {
- XCTAssertTrue(
- browserNavigationShouldOpenInNewTab(
- navigationType: .linkActivated,
- modifierFlags: [.command],
- buttonNumber: 0
- )
- )
- }
-
- func testLinkActivatedMiddleClickOpensInNewTab() {
- XCTAssertTrue(
- browserNavigationShouldOpenInNewTab(
- navigationType: .linkActivated,
- modifierFlags: [],
- buttonNumber: 2
- )
- )
- }
-
- func testLinkActivatedPlainLeftClickStaysInCurrentTab() {
- XCTAssertFalse(
- browserNavigationShouldOpenInNewTab(
- navigationType: .linkActivated,
- modifierFlags: [],
- buttonNumber: 0
- )
- )
- }
-
- func testOtherNavigationMiddleClickOpensInNewTab() {
- XCTAssertTrue(
- browserNavigationShouldOpenInNewTab(
- navigationType: .other,
- modifierFlags: [],
- buttonNumber: 2
- )
- )
- }
-
- func testOtherNavigationLeftClickStaysInCurrentTab() {
- XCTAssertFalse(
- browserNavigationShouldOpenInNewTab(
- navigationType: .other,
- modifierFlags: [],
- buttonNumber: 0
- )
- )
- }
-
- func testLinkActivatedButtonFourWithoutMiddleIntentStaysInCurrentTab() {
- XCTAssertFalse(
- browserNavigationShouldOpenInNewTab(
- navigationType: .linkActivated,
- modifierFlags: [],
- buttonNumber: 4,
- hasRecentMiddleClickIntent: false
- )
- )
- }
-
- func testLinkActivatedButtonFourWithRecentMiddleIntentOpensInNewTab() {
- XCTAssertTrue(
- browserNavigationShouldOpenInNewTab(
- navigationType: .linkActivated,
- modifierFlags: [],
- buttonNumber: 4,
- hasRecentMiddleClickIntent: true
- )
- )
- }
-
- func testLinkActivatedUsesCurrentEventFallbackForMiddleClick() {
- XCTAssertTrue(
- browserNavigationShouldOpenInNewTab(
- navigationType: .linkActivated,
- modifierFlags: [],
- buttonNumber: 0,
- currentEventType: .otherMouseUp,
- currentEventButtonNumber: 2
- )
- )
- }
-
- func testCurrentEventFallbackDoesNotAffectNonLinkNavigation() {
- XCTAssertFalse(
- browserNavigationShouldOpenInNewTab(
- navigationType: .reload,
- modifierFlags: [],
- buttonNumber: 0,
- currentEventType: .otherMouseUp,
- currentEventButtonNumber: 2
- )
- )
- }
-
- func testNonLinkNavigationNeverForcesNewTab() {
- XCTAssertFalse(
- browserNavigationShouldOpenInNewTab(
- navigationType: .reload,
- modifierFlags: [.command],
- buttonNumber: 2
- )
- )
- }
-}
-
-final class BrowserPopupDecisionTests: XCTestCase {
- func testLinkActivatedPlainLeftClickDoesNotCreatePopup() {
- XCTAssertFalse(
- browserNavigationShouldCreatePopup(
- navigationType: .linkActivated,
- modifierFlags: [],
- buttonNumber: 0
- )
- )
- }
-
- func testOtherNavigationPlainLeftClickCreatesPopup() {
- XCTAssertTrue(
- browserNavigationShouldCreatePopup(
- navigationType: .other,
- modifierFlags: [],
- buttonNumber: 0
- )
- )
- }
-
- func testOtherNavigationMiddleClickDoesNotCreatePopup() {
- XCTAssertFalse(
- browserNavigationShouldCreatePopup(
- navigationType: .other,
- modifierFlags: [],
- buttonNumber: 2
- )
- )
- }
-
- func testLinkActivatedCmdClickDoesNotCreatePopup() {
- XCTAssertFalse(
- browserNavigationShouldCreatePopup(
- navigationType: .linkActivated,
- modifierFlags: [.command],
- buttonNumber: 0
- )
- )
- }
-}
-
-final class BrowserNilTargetFallbackDecisionTests: XCTestCase {
- func testOtherNavigationDoesNotFallbackToNewTab() {
- XCTAssertFalse(
- browserNavigationShouldFallbackNilTargetToNewTab(
- navigationType: .other
- )
- )
- }
-
- func testLinkActivatedNavigationFallsBackToNewTab() {
- XCTAssertTrue(
- browserNavigationShouldFallbackNilTargetToNewTab(
- navigationType: .linkActivated
- )
- )
- }
-}
-
-final class BrowserPopupContentRectTests: XCTestCase {
- func testExplicitTopOriginCoordinatesConvertToAppKitBottomOrigin() {
- let rect = browserPopupContentRect(
- requestedWidth: 400,
- requestedHeight: 300,
- requestedX: 150,
- requestedTopY: 120,
- visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800)
- )
-
- XCTAssertEqual(rect.origin.x, 150, accuracy: 0.01)
- XCTAssertEqual(rect.origin.y, 430, accuracy: 0.01)
- XCTAssertEqual(rect.width, 400, accuracy: 0.01)
- XCTAssertEqual(rect.height, 300, accuracy: 0.01)
- }
-
- func testExplicitCoordinatesClampToVisibleFrame() {
- let rect = browserPopupContentRect(
- requestedWidth: 1400,
- requestedHeight: 1200,
- requestedX: 900,
- requestedTopY: -25,
- visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800)
- )
-
- XCTAssertEqual(rect.origin.x, 100, accuracy: 0.01)
- XCTAssertEqual(rect.origin.y, 50, accuracy: 0.01)
- XCTAssertEqual(rect.width, 1000, accuracy: 0.01)
- XCTAssertEqual(rect.height, 800, accuracy: 0.01)
- }
-
- func testMissingCoordinatesCentersPopup() {
- let rect = browserPopupContentRect(
- requestedWidth: 300,
- requestedHeight: 200,
- requestedX: nil,
- requestedTopY: nil,
- visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800)
- )
-
- XCTAssertEqual(rect.origin.x, 450, accuracy: 0.01)
- XCTAssertEqual(rect.origin.y, 350, accuracy: 0.01)
- XCTAssertEqual(rect.width, 300, accuracy: 0.01)
- XCTAssertEqual(rect.height, 200, accuracy: 0.01)
- }
-}
-
-@MainActor
-final class BrowserJavaScriptDialogDelegateTests: XCTestCase {
- func testBrowserPanelUIDelegateImplementsJavaScriptDialogSelectors() {
- let panel = BrowserPanel(workspaceId: UUID())
- guard let uiDelegate = panel.webView.uiDelegate as? NSObject else {
- XCTFail("Expected BrowserPanel webView.uiDelegate to be an NSObject")
- return
- }
-
- XCTAssertTrue(
- uiDelegate.responds(
- to: #selector(
- WKUIDelegate.webView(
- _:runJavaScriptAlertPanelWithMessage:initiatedByFrame:completionHandler:
- )
- )
- ),
- "Browser UI delegate must implement JavaScript alert handling"
- )
- XCTAssertTrue(
- uiDelegate.responds(
- to: #selector(
- WKUIDelegate.webView(
- _:runJavaScriptConfirmPanelWithMessage:initiatedByFrame:completionHandler:
- )
- )
- ),
- "Browser UI delegate must implement JavaScript confirm handling"
- )
- XCTAssertTrue(
- uiDelegate.responds(
- to: #selector(
- WKUIDelegate.webView(
- _:runJavaScriptTextInputPanelWithPrompt:defaultText:initiatedByFrame:completionHandler:
- )
- )
- ),
- "Browser UI delegate must implement JavaScript prompt handling"
- )
- }
-}
-
-@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)
- }
-
- func testWebViewReplacementAfterProcessTerminationUpdatesInstanceIdentity() {
- let panel = BrowserPanel(
- workspaceId: UUID(),
- initialURL: URL(string: "https://example.com")
- )
- let oldWebView = panel.webView
- let oldInstanceID = panel.webViewInstanceID
-
- panel.debugSimulateWebContentProcessTermination()
-
- XCTAssertFalse(panel.webView === oldWebView)
- XCTAssertNotEqual(panel.webViewInstanceID, oldInstanceID)
- XCTAssertNotNil(panel.webView.navigationDelegate)
- XCTAssertNotNil(panel.webView.uiDelegate)
- }
-
- func testWebViewReplacementPreservesEmptyNewTabRenderState() {
- let panel = BrowserPanel(workspaceId: UUID())
- XCTAssertFalse(panel.shouldRenderWebView)
-
- panel.debugSimulateWebContentProcessTermination()
-
- XCTAssertFalse(panel.shouldRenderWebView)
- }
-
- func testResetSidebarContextClearsBrowserPanelsIntoNewTabState() throws {
- let workspace = Workspace()
- let paneId = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first)
- let contextPanelId = try XCTUnwrap(workspace.focusedPanelId)
- let browser = try XCTUnwrap(
- workspace.newBrowserSurface(
- inPane: paneId,
- url: URL(string: "https://example.com"),
- focus: false
- )
- )
-
- browser.restoreSessionNavigationHistory(
- backHistoryURLStrings: ["https://example.com/prev"],
- forwardHistoryURLStrings: ["https://example.com/next"],
- currentURLString: "https://example.com/current"
- )
- browser.startFind()
-
- workspace.statusEntries["task"] = SidebarStatusEntry(key: "task", value: "Issue #1208")
- workspace.metadataBlocks["notes"] = SidebarMetadataBlock(
- key: "notes",
- markdown: "test",
- priority: 0,
- timestamp: Date()
- )
- workspace.progress = SidebarProgressState(value: 0.5, label: "Loading")
- workspace.updatePanelGitBranch(panelId: contextPanelId, branch: "issue-1208", isDirty: false)
- workspace.updatePanelPullRequest(
- panelId: contextPanelId,
- number: 1208,
- label: "PR",
- url: try XCTUnwrap(URL(string: "https://example.com/pull/1208")),
- status: .open
- )
- workspace.logEntries.append(
- SidebarLogEntry(
- message: "Issue #1208",
- level: .info,
- source: "test",
- timestamp: Date()
- )
- )
- workspace.surfaceListeningPorts[contextPanelId] = [3000]
- workspace.recomputeListeningPorts()
-
- XCTAssertTrue(browser.shouldRenderWebView)
- XCTAssertNotNil(browser.preferredURLStringForOmnibar())
- XCTAssertTrue(browser.canGoBack)
- XCTAssertTrue(browser.canGoForward)
- XCTAssertNotNil(browser.searchState)
- XCTAssertFalse(workspace.statusEntries.isEmpty)
- XCTAssertFalse(workspace.logEntries.isEmpty)
- XCTAssertFalse(workspace.metadataBlocks.isEmpty)
- XCTAssertNotNil(workspace.progress)
- XCTAssertNotNil(workspace.gitBranch)
- XCTAssertNotNil(workspace.pullRequest)
- XCTAssertEqual(workspace.listeningPorts, [3000])
-
- let priorWebView = browser.webView
- let priorInstanceID = browser.webViewInstanceID
- workspace.resetSidebarContext(reason: "test")
-
- XCTAssertTrue(workspace.statusEntries.isEmpty)
- XCTAssertTrue(workspace.logEntries.isEmpty)
- XCTAssertTrue(workspace.metadataBlocks.isEmpty)
- XCTAssertNil(workspace.progress)
- XCTAssertNil(workspace.gitBranch)
- XCTAssertTrue(workspace.panelGitBranches.isEmpty)
- XCTAssertNil(workspace.pullRequest)
- XCTAssertTrue(workspace.panelPullRequests.isEmpty)
- XCTAssertTrue(workspace.surfaceListeningPorts.isEmpty)
- XCTAssertTrue(workspace.listeningPorts.isEmpty)
- XCTAssertFalse(browser.shouldRenderWebView)
- XCTAssertNil(browser.preferredURLStringForOmnibar())
- XCTAssertFalse(browser.canGoBack)
- XCTAssertFalse(browser.canGoForward)
- XCTAssertNil(browser.searchState)
- XCTAssertFalse(browser.webView === priorWebView)
- XCTAssertNotEqual(browser.webViewInstanceID, priorInstanceID)
- }
-
-}
-
-@MainActor
-final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
- private final class WKInspectorProbeView: NSView {
- override var acceptsFirstResponder: Bool { true }
- }
-
- private final class FakeInspector: NSObject {
- enum HideBehavior {
- case unsupported
- case noEffect
- case hides
- }
-
- private(set) var attachCount = 0
- private(set) var showCount = 0
- private(set) var hideCount = 0
- private(set) var closeCount = 0
- private let hideBehavior: HideBehavior
- private var visible = false
- private var attached = false
-
- init(hideBehavior: HideBehavior = .unsupported) {
- self.hideBehavior = hideBehavior
- super.init()
- }
-
- override func responds(to aSelector: Selector!) -> Bool {
- guard NSStringFromSelector(aSelector) == "hide" else {
- return super.responds(to: aSelector)
- }
- return hideBehavior != .unsupported
- }
-
- @objc func isVisible() -> Bool {
- visible
- }
-
- @objc func isAttached() -> Bool {
- attached
- }
-
- @objc func attach() {
- attachCount += 1
- attached = true
- show()
- }
-
- @objc func show() {
- showCount += 1
- visible = true
- }
-
- @objc func hide() {
- hideCount += 1
- guard hideBehavior == .hides else { return }
- visible = false
- }
-
- @objc func close() {
- closeCount += 1
- visible = false
- attached = false
- }
- }
-
- override class func setUp() {
- super.setUp()
- installCmuxUnitTestInspectorOverride()
- }
-
- private func makePanelWithInspector(
- hideBehavior: FakeInspector.HideBehavior = .unsupported
- ) -> (BrowserPanel, FakeInspector) {
- let panel = BrowserPanel(workspaceId: UUID())
- let inspector = FakeInspector(hideBehavior: hideBehavior)
- panel.webView.cmuxSetUnitTestInspector(inspector)
- return (panel, inspector)
- }
-
- private func findHostContainerView(in root: NSView) -> WebViewRepresentable.HostContainerView? {
- if let host = root as? WebViewRepresentable.HostContainerView {
- return host
- }
- for subview in root.subviews {
- if let host = findHostContainerView(in: subview) {
- return host
- }
- }
- return nil
- }
-
- private func waitForDeveloperToolsTransitions() {
- RunLoop.current.run(until: Date().addingTimeInterval(0.5))
- }
-
- private func findWindowBrowserSlotView(in root: NSView) -> WindowBrowserSlotView? {
- if let slot = root as? WindowBrowserSlotView {
- return slot
- }
- for subview in root.subviews {
- if let slot = findWindowBrowserSlotView(in: subview) {
- return slot
- }
- }
- return nil
- }
-
- func testRestoreReopensInspectorAfterAttachWhenPreferredVisible() {
- let (panel, inspector) = makePanelWithInspector()
-
- XCTAssertTrue(panel.showDeveloperTools())
- XCTAssertTrue(panel.isDeveloperToolsVisible())
- XCTAssertEqual(inspector.showCount, 1)
-
- // Simulate WebKit closing inspector during detach/reattach churn.
- inspector.close()
- XCTAssertFalse(panel.isDeveloperToolsVisible())
- XCTAssertEqual(inspector.closeCount, 1)
-
- panel.restoreDeveloperToolsAfterAttachIfNeeded()
- XCTAssertTrue(panel.isDeveloperToolsVisible())
- XCTAssertEqual(inspector.showCount, 2)
- }
-
- func testSyncRespectsManualCloseAndPreventsUnexpectedRestore() {
- let (panel, inspector) = makePanelWithInspector()
-
- XCTAssertTrue(panel.showDeveloperTools())
- XCTAssertEqual(inspector.showCount, 1)
-
- // Simulate user closing inspector before detach.
- inspector.close()
- panel.syncDeveloperToolsPreferenceFromInspector()
-
- panel.restoreDeveloperToolsAfterAttachIfNeeded()
- XCTAssertFalse(panel.isDeveloperToolsVisible())
- XCTAssertEqual(inspector.showCount, 1)
- }
-
- func testSyncCanPreserveVisibleIntentDuringDetachChurn() {
- let (panel, inspector) = makePanelWithInspector()
-
- XCTAssertTrue(panel.showDeveloperTools())
- XCTAssertEqual(inspector.showCount, 1)
-
- // Simulate a transient close caused by view detach, not user intent.
- inspector.close()
- panel.syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: true)
- panel.restoreDeveloperToolsAfterAttachIfNeeded()
-
- XCTAssertTrue(panel.isDeveloperToolsVisible())
- XCTAssertEqual(inspector.showCount, 2)
- }
-
- func testForcedRefreshAfterAttachKeepsVisibleInspectorState() {
- let (panel, inspector) = makePanelWithInspector()
-
- XCTAssertTrue(panel.showDeveloperTools())
- XCTAssertTrue(panel.isDeveloperToolsVisible())
- XCTAssertEqual(inspector.showCount, 1)
- XCTAssertEqual(inspector.closeCount, 0)
-
- panel.requestDeveloperToolsRefreshAfterNextAttach(reason: "unit-test")
- panel.restoreDeveloperToolsAfterAttachIfNeeded()
-
- XCTAssertTrue(panel.isDeveloperToolsVisible())
- XCTAssertEqual(inspector.closeCount, 0)
- XCTAssertEqual(inspector.showCount, 1)
-
- // The force-refresh request should be one-shot.
- panel.restoreDeveloperToolsAfterAttachIfNeeded()
- XCTAssertEqual(inspector.closeCount, 0)
- XCTAssertEqual(inspector.showCount, 1)
- }
-
- func testRefreshRequestTracksPendingStateUntilRestoreRuns() {
- let (panel, _) = makePanelWithInspector()
-
- XCTAssertTrue(panel.showDeveloperTools())
- XCTAssertFalse(panel.hasPendingDeveloperToolsRefreshAfterAttach())
-
- panel.requestDeveloperToolsRefreshAfterNextAttach(reason: "unit-test")
- XCTAssertTrue(panel.hasPendingDeveloperToolsRefreshAfterAttach())
-
- panel.restoreDeveloperToolsAfterAttachIfNeeded()
- XCTAssertFalse(panel.hasPendingDeveloperToolsRefreshAfterAttach())
- }
-
- func testRapidToggleCoalescesToFinalVisibleIntentWithoutExtraInspectorCalls() {
- let (panel, inspector) = makePanelWithInspector()
-
- XCTAssertTrue(panel.toggleDeveloperTools())
- XCTAssertTrue(panel.toggleDeveloperTools())
- XCTAssertTrue(panel.toggleDeveloperTools())
- XCTAssertEqual(inspector.showCount, 1)
- XCTAssertEqual(inspector.closeCount, 0)
-
- waitForDeveloperToolsTransitions()
-
- XCTAssertTrue(panel.isDeveloperToolsVisible())
- XCTAssertEqual(inspector.showCount, 1)
- XCTAssertEqual(inspector.closeCount, 0)
- }
-
- func testRapidToggleQueuesHideAfterOpenTransitionSettles() {
- let (panel, inspector) = makePanelWithInspector()
-
- XCTAssertTrue(panel.toggleDeveloperTools())
- XCTAssertTrue(panel.toggleDeveloperTools())
- XCTAssertEqual(inspector.showCount, 1)
- XCTAssertEqual(inspector.closeCount, 0)
-
- waitForDeveloperToolsTransitions()
-
- XCTAssertFalse(panel.isDeveloperToolsVisible())
- XCTAssertEqual(inspector.showCount, 1)
- XCTAssertEqual(inspector.closeCount, 1)
- }
-
- func testToggleDeveloperToolsFallsBackToCloseWhenHideDoesNotConcealInspector() {
- let (panel, inspector) = makePanelWithInspector(hideBehavior: .noEffect)
-
- XCTAssertTrue(panel.showDeveloperTools())
- XCTAssertTrue(panel.isDeveloperToolsVisible())
-
- XCTAssertTrue(panel.toggleDeveloperTools())
-
- XCTAssertEqual(inspector.hideCount, 1)
- XCTAssertEqual(inspector.closeCount, 1)
- XCTAssertFalse(panel.isDeveloperToolsVisible())
- }
-
- func testTransientHideAttachmentPreserveFollowsDeveloperToolsIntent() {
- let (panel, _) = makePanelWithInspector()
-
- XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
- XCTAssertTrue(panel.showDeveloperTools())
- XCTAssertTrue(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
- XCTAssertTrue(panel.hideDeveloperTools())
- XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
- }
-
- func testWebViewDismantleKeepsPortalHostedWebViewAttachedWhenDeveloperToolsIntentIsVisible() {
- let (panel, _) = makePanelWithInspector()
- let paneId = PaneID(id: UUID())
- XCTAssertTrue(panel.showDeveloperTools())
-
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- let anchor = NSView(frame: NSRect(x: 30, y: 30, width: 180, height: 140))
- window.contentView?.addSubview(anchor)
- window.makeKeyAndOrderFront(nil)
- window.displayIfNeeded()
- window.contentView?.layoutSubtreeIfNeeded()
- RunLoop.current.run(until: Date().addingTimeInterval(0.05))
-
- BrowserWindowPortalRegistry.bind(webView: panel.webView, to: anchor, visibleInUI: true, zPriority: 1)
- BrowserWindowPortalRegistry.synchronizeForAnchor(anchor)
- XCTAssertNotNil(panel.webView.superview)
-
- let representable = WebViewRepresentable(
- panel: panel,
- paneId: paneId,
- shouldAttachWebView: true,
- useLocalInlineHosting: false,
- shouldFocusWebView: false,
- isPanelFocused: true,
- portalZPriority: 0,
- paneDropZone: nil,
- searchOverlay: nil,
- paneTopChromeHeight: 0
- )
- let coordinator = representable.makeCoordinator()
- coordinator.webView = panel.webView
- WebViewRepresentable.dismantleNSView(anchor, coordinator: coordinator)
-
- XCTAssertNotNil(panel.webView.superview)
- window.orderOut(nil)
- }
-
- func testWebViewDismantleKeepsPortalHostedWebViewAttachedWhenDeveloperToolsIntentIsHidden() {
- let (panel, _) = makePanelWithInspector()
- let paneId = PaneID(id: UUID())
- XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
-
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- let anchor = NSView(frame: NSRect(x: 20, y: 20, width: 200, height: 150))
- window.contentView?.addSubview(anchor)
- window.makeKeyAndOrderFront(nil)
- window.displayIfNeeded()
- window.contentView?.layoutSubtreeIfNeeded()
- RunLoop.current.run(until: Date().addingTimeInterval(0.05))
-
- BrowserWindowPortalRegistry.bind(webView: panel.webView, to: anchor, visibleInUI: true, zPriority: 1)
- BrowserWindowPortalRegistry.synchronizeForAnchor(anchor)
- XCTAssertNotNil(panel.webView.superview)
-
- let representable = WebViewRepresentable(
- panel: panel,
- paneId: paneId,
- shouldAttachWebView: true,
- useLocalInlineHosting: false,
- shouldFocusWebView: false,
- isPanelFocused: true,
- portalZPriority: 0,
- paneDropZone: nil,
- searchOverlay: nil,
- paneTopChromeHeight: 0
- )
- let coordinator = representable.makeCoordinator()
- coordinator.webView = panel.webView
- WebViewRepresentable.dismantleNSView(anchor, coordinator: coordinator)
-
- XCTAssertNotNil(panel.webView.superview)
- window.orderOut(nil)
- }
-
- func testTransientHideAttachmentPreserveDisablesForSideDockedInspectorLayout() {
- let (panel, _) = makePanelWithInspector()
- XCTAssertTrue(panel.showDeveloperTools())
-
- let host = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 240))
- panel.webView.frame = NSRect(x: 0, y: 0, width: 120, height: host.bounds.height)
- host.addSubview(panel.webView)
-
- let inspectorContainer = NSView(
- frame: NSRect(x: 120, y: 0, width: host.bounds.width - 120, height: host.bounds.height)
- )
- let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
- inspectorView.autoresizingMask = [.width, .height]
- inspectorContainer.addSubview(inspectorView)
- host.addSubview(inspectorContainer)
-
- XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
- }
-
- func testTransientHideAttachmentPreserveStaysEnabledForBottomDockedInspectorLayout() {
- let (panel, _) = makePanelWithInspector()
- XCTAssertTrue(panel.showDeveloperTools())
-
- let host = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 240))
- panel.webView.frame = NSRect(x: 0, y: 80, width: host.bounds.width, height: host.bounds.height - 80)
- host.addSubview(panel.webView)
-
- let inspectorContainer = NSView(frame: NSRect(x: 0, y: 0, width: host.bounds.width, height: 80))
- let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
- inspectorView.autoresizingMask = [.width, .height]
- inspectorContainer.addSubview(inspectorView)
- host.addSubview(inspectorContainer)
-
- XCTAssertTrue(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
- }
-
- func testOffWindowReplacementLocalHostDoesNotStealVisibleDevToolsWebView() {
- let (panel, _) = makePanelWithInspector()
- XCTAssertTrue(panel.showDeveloperTools())
-
- let paneId = PaneID(id: UUID())
- let representable = WebViewRepresentable(
- panel: panel,
- paneId: paneId,
- shouldAttachWebView: false,
- useLocalInlineHosting: true,
- shouldFocusWebView: false,
- isPanelFocused: true,
- portalZPriority: 0,
- paneDropZone: nil,
- searchOverlay: nil,
- paneTopChromeHeight: 0
- )
-
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let visibleHosting = NSHostingView(rootView: representable)
- visibleHosting.frame = contentView.bounds
- visibleHosting.autoresizingMask = [.width, .height]
- contentView.addSubview(visibleHosting)
- window.makeKeyAndOrderFront(nil)
- window.displayIfNeeded()
- contentView.layoutSubtreeIfNeeded()
- visibleHosting.layoutSubtreeIfNeeded()
- RunLoop.current.run(until: Date().addingTimeInterval(0.05))
-
- guard let visibleHost = findHostContainerView(in: visibleHosting) else {
- XCTFail("Expected visible local host")
- return
- }
- guard let visibleSlot = panel.webView.superview as? WindowBrowserSlotView else {
- XCTFail("Expected visible local inline slot")
- return
- }
-
- let inspectorView = WKInspectorProbeView(
- frame: NSRect(x: 0, y: 0, width: visibleSlot.bounds.width, height: 72)
- )
- inspectorView.autoresizingMask = [.width]
- visibleSlot.addSubview(inspectorView)
- panel.webView.frame = NSRect(
- x: 0,
- y: inspectorView.frame.maxY,
- width: visibleSlot.bounds.width,
- height: visibleSlot.bounds.height - inspectorView.frame.height
- )
- visibleSlot.layoutSubtreeIfNeeded()
-
- let detachedRoot = NSView(frame: visibleHosting.frame)
- let offWindowHosting = NSHostingView(rootView: representable)
- offWindowHosting.frame = detachedRoot.bounds
- offWindowHosting.autoresizingMask = [.width, .height]
- detachedRoot.addSubview(offWindowHosting)
- detachedRoot.layoutSubtreeIfNeeded()
- offWindowHosting.layoutSubtreeIfNeeded()
- RunLoop.current.run(until: Date().addingTimeInterval(0.05))
-
- XCTAssertNotNil(findHostContainerView(in: offWindowHosting), "Expected off-window replacement host")
- XCTAssertTrue(visibleHost.window === window)
- XCTAssertTrue(
- panel.webView.superview === visibleSlot,
- "An off-window replacement host should not steal a visible DevTools-hosted web view during split zoom churn"
- )
- XCTAssertTrue(
- inspectorView.superview === visibleSlot,
- "An off-window replacement host should leave DevTools companion views in the visible local host"
- )
- }
-
- func testVisibleReplacementLocalHostNormalizesBottomDockedInspectorFrames() {
- let (panel, _) = makePanelWithInspector()
- XCTAssertTrue(panel.showDeveloperTools())
-
- let paneId = PaneID(id: UUID())
- let representable = WebViewRepresentable(
- panel: panel,
- paneId: paneId,
- shouldAttachWebView: false,
- useLocalInlineHosting: true,
- shouldFocusWebView: false,
- isPanelFocused: true,
- portalZPriority: 0,
- paneDropZone: nil,
- searchOverlay: nil,
- paneTopChromeHeight: 0
- )
-
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let narrowHosting = NSHostingView(rootView: representable)
- narrowHosting.frame = NSRect(x: 180, y: 0, width: 180, height: 240)
- contentView.addSubview(narrowHosting)
-
- window.makeKeyAndOrderFront(nil)
- window.displayIfNeeded()
- contentView.layoutSubtreeIfNeeded()
- narrowHosting.layoutSubtreeIfNeeded()
- RunLoop.current.run(until: Date().addingTimeInterval(0.05))
-
- guard let initialSlot = panel.webView.superview as? WindowBrowserSlotView else {
- XCTFail("Expected initial local inline slot")
- return
- }
-
- let inspectorView = WKInspectorProbeView(
- frame: NSRect(x: 0, y: 0, width: initialSlot.bounds.width, height: 72)
- )
- inspectorView.autoresizingMask = [.width]
- initialSlot.addSubview(inspectorView)
- panel.webView.frame = NSRect(
- x: 0,
- y: inspectorView.frame.maxY,
- width: initialSlot.bounds.width,
- height: initialSlot.bounds.height - inspectorView.frame.height
- )
- initialSlot.layoutSubtreeIfNeeded()
-
- let replacementHosting = NSHostingView(rootView: representable)
- replacementHosting.frame = contentView.bounds
- replacementHosting.autoresizingMask = [.width, .height]
- contentView.addSubview(replacementHosting, positioned: .above, relativeTo: narrowHosting)
- contentView.layoutSubtreeIfNeeded()
- replacementHosting.layoutSubtreeIfNeeded()
- RunLoop.current.run(until: Date().addingTimeInterval(0.05))
-
- replacementHosting.rootView = representable
- contentView.layoutSubtreeIfNeeded()
- replacementHosting.layoutSubtreeIfNeeded()
- RunLoop.current.run(until: Date().addingTimeInterval(0.05))
-
- narrowHosting.removeFromSuperview()
- contentView.layoutSubtreeIfNeeded()
- replacementHosting.layoutSubtreeIfNeeded()
- RunLoop.current.run(until: Date().addingTimeInterval(0.05))
-
- guard let replacementHost = findHostContainerView(in: replacementHosting),
- let replacementSlot = findWindowBrowserSlotView(in: replacementHost) else {
- XCTFail("Expected replacement local inline host")
- return
- }
-
- XCTAssertTrue(
- panel.webView.superview === replacementSlot,
- "A visible replacement local host should take over the hosted page"
- )
- XCTAssertTrue(
- inspectorView.superview === replacementSlot,
- "A visible replacement local host should move the DevTools companion views with the page"
- )
- XCTAssertEqual(inspectorView.frame.minX, 0, accuracy: 0.5)
- XCTAssertEqual(inspectorView.frame.minY, 0, accuracy: 0.5)
- XCTAssertEqual(inspectorView.frame.width, replacementSlot.bounds.width, accuracy: 0.5)
- XCTAssertEqual(inspectorView.frame.height, 72, accuracy: 0.5)
- XCTAssertEqual(panel.webView.frame.minX, 0, accuracy: 0.5)
- XCTAssertEqual(panel.webView.frame.minY, 72, accuracy: 0.5)
- XCTAssertEqual(panel.webView.frame.width, replacementSlot.bounds.width, accuracy: 0.5)
- XCTAssertEqual(panel.webView.frame.height, replacementSlot.bounds.height - 72, accuracy: 0.5)
- }
-}
-
-final class WorkspaceShortcutMapperTests: XCTestCase {
- func testCommandNineMapsToLastWorkspaceIndex() {
- XCTAssertEqual(WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: 9, workspaceCount: 1), 0)
- XCTAssertEqual(WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: 9, workspaceCount: 4), 3)
- XCTAssertEqual(WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: 9, workspaceCount: 12), 11)
- }
-
- func testCommandDigitBadgesUseNineForLastWorkspaceWhenNeeded() {
- XCTAssertEqual(WorkspaceShortcutMapper.commandDigitForWorkspace(at: 0, workspaceCount: 12), 1)
- XCTAssertEqual(WorkspaceShortcutMapper.commandDigitForWorkspace(at: 7, workspaceCount: 12), 8)
- XCTAssertEqual(WorkspaceShortcutMapper.commandDigitForWorkspace(at: 11, workspaceCount: 12), 9)
- XCTAssertNil(WorkspaceShortcutMapper.commandDigitForWorkspace(at: 8, workspaceCount: 12))
- }
-}
-
-final class BrowserOmnibarCommandNavigationTests: XCTestCase {
- func testArrowNavigationDeltaRequiresFocusedAddressBarAndNoModifierFlags() {
- XCTAssertNil(
- browserOmnibarSelectionDeltaForArrowNavigation(
- hasFocusedAddressBar: false,
- flags: [],
- keyCode: 126
- )
- )
- XCTAssertNil(
- browserOmnibarSelectionDeltaForArrowNavigation(
- hasFocusedAddressBar: true,
- flags: [.command],
- keyCode: 126
- )
- )
- XCTAssertEqual(
- browserOmnibarSelectionDeltaForArrowNavigation(
- hasFocusedAddressBar: true,
- flags: [],
- keyCode: 126
- ),
- -1
- )
- XCTAssertEqual(
- browserOmnibarSelectionDeltaForArrowNavigation(
- hasFocusedAddressBar: true,
- flags: [],
- keyCode: 125
- ),
- 1
- )
- }
-
- func testArrowNavigationDeltaIgnoresCapsLockModifier() {
- XCTAssertEqual(
- browserOmnibarSelectionDeltaForArrowNavigation(
- hasFocusedAddressBar: true,
- flags: [.capsLock],
- keyCode: 126
- ),
- -1
- )
- XCTAssertEqual(
- browserOmnibarSelectionDeltaForArrowNavigation(
- hasFocusedAddressBar: true,
- flags: [.capsLock],
- keyCode: 125
- ),
- 1
- )
- }
-
- func testCommandNavigationDeltaRequiresFocusedAddressBarAndCommandOrControlOnly() {
- XCTAssertNil(
- browserOmnibarSelectionDeltaForCommandNavigation(
- hasFocusedAddressBar: false,
- flags: [.command],
- chars: "n"
- )
- )
-
- XCTAssertEqual(
- browserOmnibarSelectionDeltaForCommandNavigation(
- hasFocusedAddressBar: true,
- flags: [.command],
- chars: "n"
- ),
- 1
- )
-
- XCTAssertEqual(
- browserOmnibarSelectionDeltaForCommandNavigation(
- hasFocusedAddressBar: true,
- flags: [.command],
- chars: "p"
- ),
- -1
- )
-
- XCTAssertNil(
- browserOmnibarSelectionDeltaForCommandNavigation(
- hasFocusedAddressBar: true,
- flags: [.command, .shift],
- chars: "n"
- )
- )
-
- XCTAssertEqual(
- browserOmnibarSelectionDeltaForCommandNavigation(
- hasFocusedAddressBar: true,
- flags: [.control],
- chars: "p"
- ),
- -1
- )
-
- XCTAssertEqual(
- browserOmnibarSelectionDeltaForCommandNavigation(
- hasFocusedAddressBar: true,
- flags: [.control],
- chars: "n"
- ),
- 1
- )
- }
-
- func testCommandNavigationDeltaIgnoresCapsLockModifier() {
- XCTAssertEqual(
- browserOmnibarSelectionDeltaForCommandNavigation(
- hasFocusedAddressBar: true,
- flags: [.control, .capsLock],
- chars: "n"
- ),
- 1
- )
- XCTAssertEqual(
- browserOmnibarSelectionDeltaForCommandNavigation(
- hasFocusedAddressBar: true,
- flags: [.command, .capsLock],
- chars: "p"
- ),
- -1
- )
- }
-
- func testSubmitOnReturnIgnoresCapsLockModifier() {
- XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: []))
- XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.shift]))
- XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.capsLock]))
- XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.shift, .capsLock]))
- XCTAssertFalse(browserOmnibarShouldSubmitOnReturn(flags: [.command, .capsLock]))
- }
-}
-
-final class BrowserReturnKeyDownRoutingTests: XCTestCase {
- func testRoutesForReturnWhenBrowserFirstResponder() {
- XCTAssertTrue(
- shouldDispatchBrowserReturnViaFirstResponderKeyDown(
- keyCode: 36,
- firstResponderIsBrowser: true,
- flags: []
- )
- )
- }
-
- func testRoutesForKeypadEnterWhenBrowserFirstResponder() {
- XCTAssertTrue(
- shouldDispatchBrowserReturnViaFirstResponderKeyDown(
- keyCode: 76,
- firstResponderIsBrowser: true,
- flags: []
- )
- )
- }
-
- func testDoesNotRouteForNonEnterKey() {
- XCTAssertFalse(
- shouldDispatchBrowserReturnViaFirstResponderKeyDown(
- keyCode: 13,
- firstResponderIsBrowser: true,
- flags: []
- )
- )
- }
-
- func testDoesNotRouteWhenFirstResponderIsNotBrowser() {
- XCTAssertFalse(
- shouldDispatchBrowserReturnViaFirstResponderKeyDown(
- keyCode: 36,
- firstResponderIsBrowser: false,
- flags: []
- )
- )
- }
-
- func testRoutesForShiftReturnWhenBrowserFirstResponder() {
- XCTAssertTrue(
- shouldDispatchBrowserReturnViaFirstResponderKeyDown(
- keyCode: 36,
- firstResponderIsBrowser: true,
- flags: [.shift]
- )
- )
- }
-
- func testDoesNotRouteForCommandShiftReturnWhenBrowserFirstResponder() {
- XCTAssertFalse(
- shouldDispatchBrowserReturnViaFirstResponderKeyDown(
- keyCode: 36,
- firstResponderIsBrowser: true,
- flags: [.command, .shift]
- )
- )
- }
-
- func testDoesNotRouteForCommandReturnWhenBrowserFirstResponder() {
- XCTAssertFalse(
- shouldDispatchBrowserReturnViaFirstResponderKeyDown(
- keyCode: 36,
- firstResponderIsBrowser: true,
- flags: [.command]
- )
- )
- }
-
- func testDoesNotRouteForOptionReturnWhenBrowserFirstResponder() {
- XCTAssertFalse(
- shouldDispatchBrowserReturnViaFirstResponderKeyDown(
- keyCode: 36,
- firstResponderIsBrowser: true,
- flags: [.option]
- )
- )
- }
-
- func testDoesNotRouteForControlReturnWhenBrowserFirstResponder() {
- XCTAssertFalse(
- shouldDispatchBrowserReturnViaFirstResponderKeyDown(
- keyCode: 36,
- firstResponderIsBrowser: true,
- flags: [.control]
- )
- )
- }
-}
-
-final class FullScreenShortcutTests: XCTestCase {
- func testMatchesCommandControlF() {
- XCTAssertTrue(
- shouldToggleMainWindowFullScreenForCommandControlFShortcut(
- flags: [.command, .control],
- chars: "f",
- keyCode: 3
- )
- )
- }
-
- func testMatchesCommandControlFFromKeyCodeWhenCharsAreUnavailable() {
- XCTAssertTrue(
- shouldToggleMainWindowFullScreenForCommandControlFShortcut(
- flags: [.command, .control],
- chars: "",
- keyCode: 3,
- layoutCharacterProvider: { _, _ in nil }
- )
- )
- }
-
- func testDoesNotFallbackToANSIWhenLayoutTranslationReturnsNonFCharacter() {
- XCTAssertFalse(
- shouldToggleMainWindowFullScreenForCommandControlFShortcut(
- flags: [.command, .control],
- chars: "",
- keyCode: 3,
- layoutCharacterProvider: { _, _ in "u" }
- )
- )
- }
-
- func testMatchesCommandControlFWhenCommandAwareLayoutTranslationProvidesF() {
- XCTAssertTrue(
- shouldToggleMainWindowFullScreenForCommandControlFShortcut(
- flags: [.command, .control],
- chars: "",
- keyCode: 3,
- layoutCharacterProvider: { _, modifierFlags in
- modifierFlags.contains(.command) ? "f" : "u"
- }
- )
- )
- }
-
- func testMatchesCommandControlFWhenCharsAreControlSequence() {
- XCTAssertTrue(
- shouldToggleMainWindowFullScreenForCommandControlFShortcut(
- flags: [.command, .control],
- chars: "\u{06}",
- keyCode: 3,
- layoutCharacterProvider: { _, _ in nil }
- )
- )
- }
-
- func testRejectsPhysicalFWhenCharacterRepresentsDifferentLayoutKey() {
- XCTAssertFalse(
- shouldToggleMainWindowFullScreenForCommandControlFShortcut(
- flags: [.command, .control],
- chars: "u",
- keyCode: 3
- )
- )
- }
-
- func testIgnoresCapsLockForCommandControlF() {
- XCTAssertTrue(
- shouldToggleMainWindowFullScreenForCommandControlFShortcut(
- flags: [.command, .control, .capsLock],
- chars: "f",
- keyCode: 3
- )
- )
- }
-
- func testRejectsWhenControlIsMissing() {
- XCTAssertFalse(
- shouldToggleMainWindowFullScreenForCommandControlFShortcut(
- flags: [.command],
- chars: "f",
- keyCode: 3
- )
- )
- }
-
- func testRejectsAdditionalModifiers() {
- XCTAssertFalse(
- shouldToggleMainWindowFullScreenForCommandControlFShortcut(
- flags: [.command, .control, .shift],
- chars: "f",
- keyCode: 3
- )
- )
- XCTAssertFalse(
- shouldToggleMainWindowFullScreenForCommandControlFShortcut(
- flags: [.command, .control, .option],
- chars: "f",
- keyCode: 3
- )
- )
- }
-
- func testRejectsWhenCommandIsMissing() {
- XCTAssertFalse(
- shouldToggleMainWindowFullScreenForCommandControlFShortcut(
- flags: [.control],
- chars: "f",
- keyCode: 3
- )
- )
- }
-
- func testRejectsNonFKey() {
- XCTAssertFalse(
- shouldToggleMainWindowFullScreenForCommandControlFShortcut(
- flags: [.command, .control],
- chars: "r",
- keyCode: 15
- )
- )
- }
-}
-
-final class BrowserZoomShortcutActionTests: XCTestCase {
- func testZoomInSupportsEqualsAndPlusVariants() {
- XCTAssertEqual(
- browserZoomShortcutAction(flags: [.command], chars: "=", keyCode: 24),
- .zoomIn
- )
- XCTAssertEqual(
- browserZoomShortcutAction(flags: [.command], chars: "+", keyCode: 24),
- .zoomIn
- )
- XCTAssertEqual(
- browserZoomShortcutAction(flags: [.command, .shift], chars: "+", keyCode: 24),
- .zoomIn
- )
- XCTAssertEqual(
- browserZoomShortcutAction(flags: [.command], chars: "+", keyCode: 30),
- .zoomIn
- )
- }
-
- func testZoomOutSupportsMinusAndUnderscoreVariants() {
- XCTAssertEqual(
- browserZoomShortcutAction(flags: [.command], chars: "-", keyCode: 27),
- .zoomOut
- )
- XCTAssertEqual(
- browserZoomShortcutAction(flags: [.command, .shift], chars: "_", keyCode: 27),
- .zoomOut
- )
- }
-
- func testZoomInSupportsShiftedLiteralFromDifferentPhysicalKey() {
- XCTAssertEqual(
- browserZoomShortcutAction(
- flags: [.command, .shift],
- chars: ";",
- keyCode: 41,
- literalChars: "+"
- ),
- .zoomIn
- )
-
- XCTAssertNil(
- browserZoomShortcutAction(
- flags: [.command, .shift],
- chars: ";",
- keyCode: 41
- )
- )
- }
-
- func testZoomRequiresCommandWithoutOptionOrControl() {
- XCTAssertNil(browserZoomShortcutAction(flags: [], chars: "=", keyCode: 24))
- XCTAssertNil(browserZoomShortcutAction(flags: [.command, .option], chars: "=", keyCode: 24))
- XCTAssertNil(browserZoomShortcutAction(flags: [.command, .control], chars: "-", keyCode: 27))
- }
-
- func testResetSupportsCommandZero() {
- XCTAssertEqual(
- browserZoomShortcutAction(flags: [.command], chars: "0", keyCode: 29),
- .reset
- )
- }
-}
-
-final class BrowserZoomShortcutRoutingPolicyTests: XCTestCase {
- func testRoutesWhenGhosttyIsFirstResponderAndShortcutIsZoom() {
- XCTAssertTrue(
- shouldRouteTerminalFontZoomShortcutToGhostty(
- firstResponderIsGhostty: true,
- flags: [.command],
- chars: "=",
- keyCode: 24
- )
- )
- XCTAssertTrue(
- shouldRouteTerminalFontZoomShortcutToGhostty(
- firstResponderIsGhostty: true,
- flags: [.command],
- chars: "-",
- keyCode: 27
- )
- )
- XCTAssertTrue(
- shouldRouteTerminalFontZoomShortcutToGhostty(
- firstResponderIsGhostty: true,
- flags: [.command],
- chars: "0",
- keyCode: 29
- )
- )
- }
-
- func testDoesNotRouteWhenFirstResponderIsNotGhostty() {
- XCTAssertFalse(
- shouldRouteTerminalFontZoomShortcutToGhostty(
- firstResponderIsGhostty: false,
- flags: [.command],
- chars: "=",
- keyCode: 24
- )
- )
- }
-
- func testDoesNotRouteForNonZoomShortcuts() {
- XCTAssertFalse(
- shouldRouteTerminalFontZoomShortcutToGhostty(
- firstResponderIsGhostty: true,
- flags: [.command],
- chars: "n",
- keyCode: 45
- )
- )
- }
-
- func testRoutesForShiftedLiteralZoomShortcut() {
- XCTAssertTrue(
- shouldRouteTerminalFontZoomShortcutToGhostty(
- firstResponderIsGhostty: true,
- flags: [.command, .shift],
- chars: ";",
- keyCode: 41,
- literalChars: "+"
- )
- )
- }
-}
-
-final class GhosttyResponderResolutionTests: XCTestCase {
- private final class FocusProbeView: NSView {
- override var acceptsFirstResponder: Bool { true }
- }
-
- func testResolvesGhosttyViewFromDescendantResponder() {
- let ghosttyView = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 200, height: 120))
- let descendant = FocusProbeView(frame: NSRect(x: 0, y: 0, width: 40, height: 40))
- ghosttyView.addSubview(descendant)
-
- XCTAssertTrue(cmuxOwningGhosttyView(for: descendant) === ghosttyView)
- }
-
- func testResolvesGhosttyViewFromGhosttyResponder() {
- let ghosttyView = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 200, height: 120))
- XCTAssertTrue(cmuxOwningGhosttyView(for: ghosttyView) === ghosttyView)
- }
-
- func testReturnsNilForUnrelatedResponder() {
- let view = FocusProbeView(frame: NSRect(x: 0, y: 0, width: 40, height: 40))
- XCTAssertNil(cmuxOwningGhosttyView(for: view))
- }
-}
-
-final class CommandPaletteKeyboardNavigationTests: XCTestCase {
- func testArrowKeysMoveSelectionWithoutModifiers() {
- XCTAssertEqual(
- commandPaletteSelectionDeltaForKeyboardNavigation(
- flags: [],
- chars: "",
- keyCode: 125
- ),
- 1
- )
- XCTAssertEqual(
- commandPaletteSelectionDeltaForKeyboardNavigation(
- flags: [],
- chars: "",
- keyCode: 126
- ),
- -1
- )
- XCTAssertNil(
- commandPaletteSelectionDeltaForKeyboardNavigation(
- flags: [.shift],
- chars: "",
- keyCode: 125
- )
- )
- }
-
- func testControlLetterNavigationSupportsPrintableAndControlChars() {
- XCTAssertEqual(
- commandPaletteSelectionDeltaForKeyboardNavigation(
- flags: [.control],
- chars: "n",
- keyCode: 45
- ),
- 1
- )
- XCTAssertEqual(
- commandPaletteSelectionDeltaForKeyboardNavigation(
- flags: [.control],
- chars: "\u{0e}",
- keyCode: 45
- ),
- 1
- )
- XCTAssertEqual(
- commandPaletteSelectionDeltaForKeyboardNavigation(
- flags: [.control],
- chars: "p",
- keyCode: 35
- ),
- -1
- )
- XCTAssertEqual(
- commandPaletteSelectionDeltaForKeyboardNavigation(
- flags: [.control],
- chars: "\u{10}",
- keyCode: 35
- ),
- -1
- )
- XCTAssertEqual(
- commandPaletteSelectionDeltaForKeyboardNavigation(
- flags: [.control],
- chars: "j",
- keyCode: 38
- ),
- 1
- )
- XCTAssertEqual(
- commandPaletteSelectionDeltaForKeyboardNavigation(
- flags: [.control],
- chars: "\u{0a}",
- keyCode: 38
- ),
- 1
- )
- XCTAssertEqual(
- commandPaletteSelectionDeltaForKeyboardNavigation(
- flags: [.control],
- chars: "k",
- keyCode: 40
- ),
- -1
- )
- XCTAssertEqual(
- commandPaletteSelectionDeltaForKeyboardNavigation(
- flags: [.control],
- chars: "\u{0b}",
- keyCode: 40
- ),
- -1
- )
- }
-
- func testIgnoresUnsupportedModifiersAndKeys() {
- XCTAssertNil(
- commandPaletteSelectionDeltaForKeyboardNavigation(
- flags: [.command],
- chars: "n",
- keyCode: 45
- )
- )
- XCTAssertNil(
- commandPaletteSelectionDeltaForKeyboardNavigation(
- flags: [.control, .shift],
- chars: "n",
- keyCode: 45
- )
- )
- XCTAssertNil(
- commandPaletteSelectionDeltaForKeyboardNavigation(
- flags: [.control],
- chars: "x",
- keyCode: 7
- )
- )
- }
-}
-
-final class CommandPaletteOpenShortcutConsumptionTests: XCTestCase {
- func testDoesNotConsumeWhenPaletteIsNotVisible() {
- XCTAssertFalse(
- shouldConsumeShortcutWhileCommandPaletteVisible(
- isCommandPaletteVisible: false,
- normalizedFlags: [.command],
- chars: "n",
- keyCode: 45
- )
- )
- }
-
- func testConsumesAppCommandShortcutsWhenPaletteIsVisible() {
- XCTAssertTrue(
- shouldConsumeShortcutWhileCommandPaletteVisible(
- isCommandPaletteVisible: true,
- normalizedFlags: [.command],
- chars: "n",
- keyCode: 45
- )
- )
- XCTAssertTrue(
- shouldConsumeShortcutWhileCommandPaletteVisible(
- isCommandPaletteVisible: true,
- normalizedFlags: [.command],
- chars: "t",
- keyCode: 17
- )
- )
- XCTAssertTrue(
- shouldConsumeShortcutWhileCommandPaletteVisible(
- isCommandPaletteVisible: true,
- normalizedFlags: [.command, .shift],
- chars: ",",
- keyCode: 43
- )
- )
- }
-
- func testAllowsClipboardAndUndoShortcutsForPaletteTextEditing() {
- XCTAssertFalse(
- shouldConsumeShortcutWhileCommandPaletteVisible(
- isCommandPaletteVisible: true,
- normalizedFlags: [.command],
- chars: "v",
- keyCode: 9
- )
- )
- XCTAssertFalse(
- shouldConsumeShortcutWhileCommandPaletteVisible(
- isCommandPaletteVisible: true,
- normalizedFlags: [.command],
- chars: "z",
- keyCode: 6
- )
- )
- XCTAssertFalse(
- shouldConsumeShortcutWhileCommandPaletteVisible(
- isCommandPaletteVisible: true,
- normalizedFlags: [.command, .shift],
- chars: "z",
- keyCode: 6
- )
- )
- }
-
- func testAllowsArrowAndDeleteEditingCommandsForPaletteTextEditing() {
- XCTAssertFalse(
- shouldConsumeShortcutWhileCommandPaletteVisible(
- isCommandPaletteVisible: true,
- normalizedFlags: [.command],
- chars: "",
- keyCode: 123
- )
- )
- XCTAssertFalse(
- shouldConsumeShortcutWhileCommandPaletteVisible(
- isCommandPaletteVisible: true,
- normalizedFlags: [.command],
- chars: "",
- keyCode: 51
- )
- )
- }
-
- func testConsumesEscapeWhenPaletteIsVisible() {
- XCTAssertTrue(
- shouldConsumeShortcutWhileCommandPaletteVisible(
- isCommandPaletteVisible: true,
- normalizedFlags: [],
- chars: "",
- keyCode: 53
- )
- )
- }
-}
-
-final class CommandPaletteRestoreFocusStateMachineTests: XCTestCase {
- func testRestoresBrowserAddressBarWhenPaletteOpenedFromFocusedAddressBar() {
- let panelId = UUID()
- XCTAssertTrue(
- ContentView.shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss(
- focusedPanelIsBrowser: true,
- focusedBrowserAddressBarPanelId: panelId,
- focusedPanelId: panelId
- )
- )
- }
-
- func testDoesNotRestoreBrowserAddressBarWhenFocusedPanelIsNotBrowser() {
- let panelId = UUID()
- XCTAssertFalse(
- ContentView.shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss(
- focusedPanelIsBrowser: false,
- focusedBrowserAddressBarPanelId: panelId,
- focusedPanelId: panelId
- )
- )
- }
-
- func testDoesNotRestoreBrowserAddressBarWhenAnotherPanelHadAddressBarFocus() {
- XCTAssertFalse(
- ContentView.shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss(
- focusedPanelIsBrowser: true,
- focusedBrowserAddressBarPanelId: UUID(),
- focusedPanelId: UUID()
- )
- )
- }
-}
-
-final class CommandPaletteRenameSelectionSettingsTests: XCTestCase {
- private let suiteName = "cmux.tests.commandPaletteRenameSelection.\(UUID().uuidString)"
-
- private func makeDefaults() -> UserDefaults {
- let defaults = UserDefaults(suiteName: suiteName)!
- defaults.removePersistentDomain(forName: suiteName)
- return defaults
- }
-
- func testDefaultsToSelectAllWhenUnset() {
- let defaults = makeDefaults()
- XCTAssertTrue(CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled(defaults: defaults))
- }
-
- func testReturnsFalseWhenStoredFalse() {
- let defaults = makeDefaults()
- defaults.set(false, forKey: CommandPaletteRenameSelectionSettings.selectAllOnFocusKey)
- XCTAssertFalse(CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled(defaults: defaults))
- }
-
- func testReturnsTrueWhenStoredTrue() {
- let defaults = makeDefaults()
- defaults.set(true, forKey: CommandPaletteRenameSelectionSettings.selectAllOnFocusKey)
- XCTAssertTrue(CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled(defaults: defaults))
- }
-}
-
-final class CommandPaletteSelectionScrollBehaviorTests: XCTestCase {
- func testFirstEntryPinsToTopAnchor() {
- let anchor = ContentView.commandPaletteScrollPositionAnchor(
- selectedIndex: 0,
- resultCount: 20
- )
- XCTAssertEqual(anchor, UnitPoint.top)
- }
-
- func testLastEntryPinsToBottomAnchor() {
- let anchor = ContentView.commandPaletteScrollPositionAnchor(
- selectedIndex: 19,
- resultCount: 20
- )
- XCTAssertEqual(anchor, UnitPoint.bottom)
- }
-
- func testMiddleEntryUsesNilAnchorForMinimalScroll() {
- let anchor = ContentView.commandPaletteScrollPositionAnchor(
- selectedIndex: 6,
- resultCount: 20
- )
- XCTAssertNil(anchor)
- }
-
- func testEmptyResultsProduceNoAnchor() {
- let anchor = ContentView.commandPaletteScrollPositionAnchor(
- selectedIndex: 0,
- resultCount: 0
- )
- XCTAssertNil(anchor)
- }
-}
-
-final class ShortcutHintModifierPolicyTests: XCTestCase {
- func testShortcutHintRequiresEnabledCommandOnlyModifier() {
- withDefaultsSuite { defaults in
- defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey)
-
- XCTAssertTrue(ShortcutHintModifierPolicy.shouldShowHints(for: [.command], defaults: defaults))
- XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control], defaults: defaults))
- XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [], defaults: defaults))
- XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.command, .shift], defaults: defaults))
- XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control, .shift], defaults: defaults))
- XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.command, .option], defaults: defaults))
- XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control, .option], defaults: defaults))
- XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.command, .control], defaults: defaults))
- }
- }
-
- func testCommandHintCanBeDisabledInSettings() {
- withDefaultsSuite { defaults in
- defaults.set(false, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey)
-
- XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.command], defaults: defaults))
- XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control], defaults: defaults))
- }
- }
-
- func testCommandHintDefaultsToEnabledWhenSettingMissing() {
- withDefaultsSuite { defaults in
- defaults.removeObject(forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey)
-
- XCTAssertTrue(ShortcutHintModifierPolicy.shouldShowHints(for: [.command], defaults: defaults))
- XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control], defaults: defaults))
- }
- }
-
- func testShortcutHintUsesIntentionalHoldDelay() {
- XCTAssertEqual(ShortcutHintModifierPolicy.intentionalHoldDelay, 0.30, accuracy: 0.001)
- }
-
- func testCurrentWindowRequiresHostWindowToBeKeyAndMatchEventWindow() {
- XCTAssertTrue(
- ShortcutHintModifierPolicy.isCurrentWindow(
- hostWindowNumber: 42,
- hostWindowIsKey: true,
- eventWindowNumber: 42,
- keyWindowNumber: 42
- )
- )
-
- XCTAssertFalse(
- ShortcutHintModifierPolicy.isCurrentWindow(
- hostWindowNumber: 42,
- hostWindowIsKey: true,
- eventWindowNumber: 7,
- keyWindowNumber: 42
- )
- )
-
- XCTAssertFalse(
- ShortcutHintModifierPolicy.isCurrentWindow(
- hostWindowNumber: 42,
- hostWindowIsKey: false,
- eventWindowNumber: 42,
- keyWindowNumber: 42
- )
- )
- }
-
- func testWindowScopedShortcutHintsUseKeyWindowWhenNoEventWindowIsAvailable() {
- withDefaultsSuite { defaults in
- defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey)
-
- XCTAssertTrue(
- ShortcutHintModifierPolicy.shouldShowHints(
- for: [.command],
- hostWindowNumber: 42,
- hostWindowIsKey: true,
- eventWindowNumber: nil,
- keyWindowNumber: 42,
- defaults: defaults
- )
- )
-
- XCTAssertFalse(
- ShortcutHintModifierPolicy.shouldShowHints(
- for: [.command],
- hostWindowNumber: 42,
- hostWindowIsKey: true,
- eventWindowNumber: nil,
- keyWindowNumber: 7,
- defaults: defaults
- )
- )
-
- XCTAssertTrue(
- ShortcutHintModifierPolicy.shouldShowHints(
- for: [.command],
- hostWindowNumber: 42,
- hostWindowIsKey: true,
- eventWindowNumber: nil,
- keyWindowNumber: 42,
- defaults: defaults
- )
- )
-
- XCTAssertFalse(
- ShortcutHintModifierPolicy.shouldShowHints(
- for: [.control],
- hostWindowNumber: 42,
- hostWindowIsKey: true,
- eventWindowNumber: nil,
- keyWindowNumber: 42,
- defaults: defaults
- )
- )
- }
- }
-
- private func withDefaultsSuite(_ body: (UserDefaults) -> Void) {
- let suiteName = "ShortcutHintModifierPolicyTests-\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create defaults suite")
- return
- }
-
- defaults.removePersistentDomain(forName: suiteName)
- body(defaults)
- defaults.removePersistentDomain(forName: suiteName)
- }
-}
-
-final class ShortcutHintDebugSettingsTests: XCTestCase {
- func testClampKeepsValuesWithinSupportedRange() {
- XCTAssertEqual(ShortcutHintDebugSettings.clamped(0.0), 0.0)
- XCTAssertEqual(ShortcutHintDebugSettings.clamped(4.0), 4.0)
- XCTAssertEqual(ShortcutHintDebugSettings.clamped(-100.0), ShortcutHintDebugSettings.offsetRange.lowerBound)
- XCTAssertEqual(ShortcutHintDebugSettings.clamped(100.0), ShortcutHintDebugSettings.offsetRange.upperBound)
- }
-
- func testDefaultOffsetsMatchCurrentBadgePlacements() {
- XCTAssertEqual(ShortcutHintDebugSettings.defaultSidebarHintX, 0.0)
- XCTAssertEqual(ShortcutHintDebugSettings.defaultSidebarHintY, 0.0)
- XCTAssertEqual(ShortcutHintDebugSettings.defaultTitlebarHintX, 4.0)
- XCTAssertEqual(ShortcutHintDebugSettings.defaultTitlebarHintY, 0.0)
- XCTAssertEqual(ShortcutHintDebugSettings.defaultPaneHintX, 0.0)
- XCTAssertEqual(ShortcutHintDebugSettings.defaultPaneHintY, 0.0)
- XCTAssertFalse(ShortcutHintDebugSettings.defaultAlwaysShowHints)
- XCTAssertTrue(ShortcutHintDebugSettings.defaultShowHintsOnCommandHold)
- }
-
- func testShowHintsOnCommandHoldSettingRespectsStoredValue() {
- let suiteName = "ShortcutHintDebugSettingsTests-\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create defaults suite")
- return
- }
-
- defaults.removePersistentDomain(forName: suiteName)
- defer { defaults.removePersistentDomain(forName: suiteName) }
-
- defaults.removeObject(forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey)
- XCTAssertTrue(ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults))
-
- defaults.set(false, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey)
- XCTAssertFalse(ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults))
-
- defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey)
- XCTAssertTrue(ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults))
- }
-
- func testResetVisibilityDefaultsRestoresAlwaysShowAndCommandHoldFlags() {
- let suiteName = "ShortcutHintDebugSettingsTests-\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create defaults suite")
- return
- }
-
- defaults.removePersistentDomain(forName: suiteName)
- defer { defaults.removePersistentDomain(forName: suiteName) }
-
- defaults.set(true, forKey: ShortcutHintDebugSettings.alwaysShowHintsKey)
- defaults.set(false, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey)
-
- ShortcutHintDebugSettings.resetVisibilityDefaults(defaults: defaults)
-
- XCTAssertEqual(
- defaults.object(forKey: ShortcutHintDebugSettings.alwaysShowHintsKey) as? Bool,
- ShortcutHintDebugSettings.defaultAlwaysShowHints
- )
- XCTAssertEqual(
- defaults.object(forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) as? Bool,
- ShortcutHintDebugSettings.defaultShowHintsOnCommandHold
- )
- }
-}
-
-final class DevBuildBannerDebugSettingsTests: XCTestCase {
- func testShowSidebarBannerDefaultsToVisible() {
- let suiteName = "DevBuildBannerDebugSettingsTests.Default.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer { defaults.removePersistentDomain(forName: suiteName) }
-
- defaults.removeObject(forKey: DevBuildBannerDebugSettings.sidebarBannerVisibleKey)
- XCTAssertTrue(DevBuildBannerDebugSettings.showSidebarBanner(defaults: defaults))
- }
-
- func testShowSidebarBannerRespectsStoredValue() {
- let suiteName = "DevBuildBannerDebugSettingsTests.Stored.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer { defaults.removePersistentDomain(forName: suiteName) }
-
- defaults.set(false, forKey: DevBuildBannerDebugSettings.sidebarBannerVisibleKey)
- XCTAssertFalse(DevBuildBannerDebugSettings.showSidebarBanner(defaults: defaults))
-
- defaults.set(true, forKey: DevBuildBannerDebugSettings.sidebarBannerVisibleKey)
- XCTAssertTrue(DevBuildBannerDebugSettings.showSidebarBanner(defaults: defaults))
- }
-}
-
-final class ShortcutHintLanePlannerTests: XCTestCase {
- func testAssignLanesKeepsSeparatedIntervalsOnSingleLane() {
- let intervals: [ClosedRange] = [0...20, 28...40, 48...64]
- XCTAssertEqual(ShortcutHintLanePlanner.assignLanes(for: intervals, minSpacing: 4), [0, 0, 0])
- }
-
- func testAssignLanesStacksOverlappingIntervalsIntoAdditionalLanes() {
- let intervals: [ClosedRange] = [0...20, 18...34, 22...38, 40...56]
- XCTAssertEqual(ShortcutHintLanePlanner.assignLanes(for: intervals, minSpacing: 4), [0, 1, 2, 0])
- }
-}
-
-final class ShortcutHintHorizontalPlannerTests: XCTestCase {
- func testAssignRightEdgesResolvesOverlapWithMinimumSpacing() {
- let intervals: [ClosedRange] = [0...20, 18...34, 30...46]
- let rightEdges = ShortcutHintHorizontalPlanner.assignRightEdges(for: intervals, minSpacing: 6)
-
- XCTAssertEqual(rightEdges.count, intervals.count)
-
- let adjustedIntervals = zip(intervals, rightEdges).map { interval, rightEdge in
- let width = interval.upperBound - interval.lowerBound
- return (rightEdge - width)...rightEdge
- }
-
- XCTAssertGreaterThanOrEqual(adjustedIntervals[1].lowerBound - adjustedIntervals[0].upperBound, 6)
- XCTAssertGreaterThanOrEqual(adjustedIntervals[2].lowerBound - adjustedIntervals[1].upperBound, 6)
- }
-
- func testAssignRightEdgesKeepsAlreadySeparatedIntervalsInPlace() {
- let intervals: [ClosedRange] = [0...12, 20...32, 40...52]
- let rightEdges = ShortcutHintHorizontalPlanner.assignRightEdges(for: intervals, minSpacing: 4)
- XCTAssertEqual(rightEdges, [12, 32, 52])
- }
-}
-
-final class WorkspacePlacementSettingsTests: XCTestCase {
- func testCurrentPlacementDefaultsToAfterCurrentWhenUnset() {
- let suiteName = "WorkspacePlacementSettingsTests.Default.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer { defaults.removePersistentDomain(forName: suiteName) }
-
- XCTAssertEqual(WorkspacePlacementSettings.current(defaults: defaults), .afterCurrent)
- }
-
- func testCurrentPlacementReadsStoredValidValueAndFallsBackForInvalid() {
- let suiteName = "WorkspacePlacementSettingsTests.Stored.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer { defaults.removePersistentDomain(forName: suiteName) }
-
- defaults.set(NewWorkspacePlacement.top.rawValue, forKey: WorkspacePlacementSettings.placementKey)
- XCTAssertEqual(WorkspacePlacementSettings.current(defaults: defaults), .top)
-
- defaults.set("nope", forKey: WorkspacePlacementSettings.placementKey)
- XCTAssertEqual(WorkspacePlacementSettings.current(defaults: defaults), .afterCurrent)
- }
-
- func testInsertionIndexTopInsertsBeforeUnpinned() {
- let index = WorkspacePlacementSettings.insertionIndex(
- placement: .top,
- selectedIndex: 4,
- selectedIsPinned: false,
- pinnedCount: 2,
- totalCount: 7
- )
- XCTAssertEqual(index, 2)
- }
-
- func testInsertionIndexAfterCurrentHandlesPinnedAndUnpinnedSelection() {
- let afterUnpinned = WorkspacePlacementSettings.insertionIndex(
- placement: .afterCurrent,
- selectedIndex: 3,
- selectedIsPinned: false,
- pinnedCount: 2,
- totalCount: 6
- )
- XCTAssertEqual(afterUnpinned, 4)
-
- let afterPinned = WorkspacePlacementSettings.insertionIndex(
- placement: .afterCurrent,
- selectedIndex: 0,
- selectedIsPinned: true,
- pinnedCount: 2,
- totalCount: 6
- )
- XCTAssertEqual(afterPinned, 2)
- }
-
- func testInsertionIndexEndAndNoSelectionAppend() {
- let endIndex = WorkspacePlacementSettings.insertionIndex(
- placement: .end,
- selectedIndex: 1,
- selectedIsPinned: false,
- pinnedCount: 1,
- totalCount: 5
- )
- XCTAssertEqual(endIndex, 5)
-
- let noSelectionIndex = WorkspacePlacementSettings.insertionIndex(
- placement: .afterCurrent,
- selectedIndex: nil,
- selectedIsPinned: false,
- pinnedCount: 0,
- totalCount: 5
- )
- XCTAssertEqual(noSelectionIndex, 5)
- }
-}
-
-@MainActor
-final class WorkspaceCreationPlacementTests: XCTestCase {
- func testAddWorkspaceDefaultPlacementMatchesCurrentSetting() {
- let currentPlacement = WorkspacePlacementSettings.current()
-
- let defaultManager = makeManagerWithThreeWorkspaces()
- let defaultBaselineOrder = defaultManager.tabs.map(\.id)
- let defaultInserted = defaultManager.addWorkspace()
- guard let defaultInsertedIndex = defaultManager.tabs.firstIndex(where: { $0.id == defaultInserted.id }) else {
- XCTFail("Expected inserted workspace in tab list")
- return
- }
- XCTAssertEqual(defaultManager.tabs.map(\.id).filter { $0 != defaultInserted.id }, defaultBaselineOrder)
-
- let explicitManager = makeManagerWithThreeWorkspaces()
- let explicitBaselineOrder = explicitManager.tabs.map(\.id)
- let explicitInserted = explicitManager.addWorkspace(placementOverride: currentPlacement)
- guard let explicitInsertedIndex = explicitManager.tabs.firstIndex(where: { $0.id == explicitInserted.id }) else {
- XCTFail("Expected inserted workspace in tab list")
- return
- }
- XCTAssertEqual(explicitManager.tabs.map(\.id).filter { $0 != explicitInserted.id }, explicitBaselineOrder)
- XCTAssertEqual(defaultInsertedIndex, explicitInsertedIndex)
- }
-
- func testAddWorkspaceEndOverrideAlwaysAppends() {
- let manager = makeManagerWithThreeWorkspaces()
- let baselineCount = manager.tabs.count
- guard baselineCount >= 3 else {
- XCTFail("Expected at least three workspaces for placement regression test")
- return
- }
-
- let inserted = manager.addWorkspace(placementOverride: .end)
- guard let insertedIndex = manager.tabs.firstIndex(where: { $0.id == inserted.id }) else {
- XCTFail("Expected inserted workspace in tab list")
- return
- }
-
- XCTAssertEqual(insertedIndex, baselineCount)
- }
-
- private func makeManagerWithThreeWorkspaces() -> TabManager {
- let manager = TabManager()
- _ = manager.addWorkspace()
- _ = manager.addWorkspace()
- if let first = manager.tabs.first {
- manager.selectWorkspace(first)
- }
- return manager
- }
-}
-
-final class WorkspaceTabColorSettingsTests: XCTestCase {
- func testNormalizedHexAcceptsAndNormalizesValidInput() {
- XCTAssertEqual(WorkspaceTabColorSettings.normalizedHex("#abc123"), "#ABC123")
- XCTAssertEqual(WorkspaceTabColorSettings.normalizedHex(" aBcDeF "), "#ABCDEF")
- XCTAssertNil(WorkspaceTabColorSettings.normalizedHex("#1234"))
- XCTAssertNil(WorkspaceTabColorSettings.normalizedHex("#GG1234"))
- }
-
- func testBuiltInPaletteMatchesOriginalPRPalette() {
- let suiteName = "WorkspaceTabColorSettingsTests.BuiltInPalette.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer { defaults.removePersistentDomain(forName: suiteName) }
-
- let palette = WorkspaceTabColorSettings.defaultPaletteWithOverrides(defaults: defaults)
- XCTAssertEqual(palette.count, 16)
- XCTAssertEqual(palette.first?.name, "Red")
- XCTAssertEqual(palette.first?.hex, "#C0392B")
- XCTAssertEqual(palette.last?.name, "Charcoal")
- XCTAssertFalse(palette.contains(where: { $0.name == "Gold" }))
- }
-
- func testDefaultOverrideRoundTripFallsBackWhenResetToBase() {
- let suiteName = "WorkspaceTabColorSettingsTests.DefaultOverride.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer { defaults.removePersistentDomain(forName: suiteName) }
-
- let first = WorkspaceTabColorSettings.defaultPalette[0]
- XCTAssertEqual(
- WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults),
- first.hex
- )
-
- WorkspaceTabColorSettings.setDefaultColor(named: first.name, hex: "#00aa33", defaults: defaults)
- XCTAssertEqual(
- WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults),
- "#00AA33"
- )
-
- WorkspaceTabColorSettings.setDefaultColor(named: first.name, hex: first.hex, defaults: defaults)
- XCTAssertEqual(
- WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults),
- first.hex
- )
- }
-
- func testAddCustomColorPersistsAndDeduplicatesByMostRecent() {
- let suiteName = "WorkspaceTabColorSettingsTests.CustomColors.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer { defaults.removePersistentDomain(forName: suiteName) }
-
- XCTAssertEqual(
- WorkspaceTabColorSettings.addCustomColor(" #00aa33 ", defaults: defaults),
- "#00AA33"
- )
- XCTAssertEqual(
- WorkspaceTabColorSettings.addCustomColor("#112233", defaults: defaults),
- "#112233"
- )
- XCTAssertEqual(
- WorkspaceTabColorSettings.addCustomColor("#00AA33", defaults: defaults),
- "#00AA33"
- )
- XCTAssertNil(WorkspaceTabColorSettings.addCustomColor("nope", defaults: defaults))
-
- XCTAssertEqual(
- WorkspaceTabColorSettings.customColors(defaults: defaults),
- ["#00AA33", "#112233"]
- )
- }
-
- func testPaletteIncludesCustomEntriesAndResetClearsAll() {
- let suiteName = "WorkspaceTabColorSettingsTests.Reset.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer { defaults.removePersistentDomain(forName: suiteName) }
-
- let first = WorkspaceTabColorSettings.defaultPalette[0]
- WorkspaceTabColorSettings.setDefaultColor(named: first.name, hex: "#334455", defaults: defaults)
- _ = WorkspaceTabColorSettings.addCustomColor("#778899", defaults: defaults)
-
- let paletteBeforeReset = WorkspaceTabColorSettings.palette(defaults: defaults)
- XCTAssertEqual(paletteBeforeReset.count, WorkspaceTabColorSettings.defaultPalette.count + 1)
- XCTAssertEqual(paletteBeforeReset[0].hex, "#334455")
- XCTAssertEqual(paletteBeforeReset.last?.name, "Custom 1")
- XCTAssertEqual(paletteBeforeReset.last?.hex, "#778899")
-
- WorkspaceTabColorSettings.reset(defaults: defaults)
-
- XCTAssertEqual(WorkspaceTabColorSettings.customColors(defaults: defaults), [])
- XCTAssertEqual(
- WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults),
- first.hex
- )
- }
-
- func testDisplayColorLightModeKeepsOriginalHex() {
- let originalHex = "#1A5276"
- let rendered = WorkspaceTabColorSettings.displayNSColor(
- hex: originalHex,
- colorScheme: .light
- )
-
- XCTAssertEqual(rendered?.hexString(), originalHex)
- }
-
- func testDisplayColorDarkModeBrightensColor() {
- let originalHex = "#1A5276"
- guard let base = NSColor(hex: originalHex),
- let rendered = WorkspaceTabColorSettings.displayNSColor(
- hex: originalHex,
- colorScheme: .dark
- ) else {
- XCTFail("Expected valid color conversion")
- return
- }
-
- XCTAssertNotEqual(rendered.hexString(), originalHex)
- XCTAssertGreaterThan(rendered.luminance, base.luminance)
- }
-
- func testDisplayColorDarkModeKeepsGrayscaleNeutral() {
- let originalHex = "#808080"
- guard let base = NSColor(hex: originalHex),
- let rendered = WorkspaceTabColorSettings.displayNSColor(
- hex: originalHex,
- colorScheme: .dark
- ),
- let renderedSRGB = rendered.usingColorSpace(.sRGB) else {
- XCTFail("Expected valid color conversion")
- return
- }
-
- XCTAssertGreaterThan(rendered.luminance, base.luminance)
- XCTAssertLessThan(abs(renderedSRGB.redComponent - renderedSRGB.greenComponent), 0.003)
- XCTAssertLessThan(abs(renderedSRGB.greenComponent - renderedSRGB.blueComponent), 0.003)
- }
-
- func testDisplayColorForceBrightensInLightMode() {
- let originalHex = "#1A5276"
- guard let base = NSColor(hex: originalHex),
- let rendered = WorkspaceTabColorSettings.displayNSColor(
- hex: originalHex,
- colorScheme: .light,
- forceBright: true
- ) else {
- XCTFail("Expected valid color conversion")
- return
- }
-
- XCTAssertNotEqual(rendered.hexString(), originalHex)
- XCTAssertGreaterThan(rendered.luminance, base.luminance)
- }
-}
-
-final class WorkspaceAutoReorderSettingsTests: XCTestCase {
- func testDefaultIsEnabled() {
- let suiteName = "WorkspaceAutoReorderSettingsTests.Default.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer { defaults.removePersistentDomain(forName: suiteName) }
-
- XCTAssertTrue(WorkspaceAutoReorderSettings.isEnabled(defaults: defaults))
- }
-
- func testDisabledWhenSetToFalse() {
- let suiteName = "WorkspaceAutoReorderSettingsTests.Disabled.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer { defaults.removePersistentDomain(forName: suiteName) }
-
- defaults.set(false, forKey: WorkspaceAutoReorderSettings.key)
- XCTAssertFalse(WorkspaceAutoReorderSettings.isEnabled(defaults: defaults))
- }
-
- func testEnabledWhenSetToTrue() {
- let suiteName = "WorkspaceAutoReorderSettingsTests.Enabled.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer { defaults.removePersistentDomain(forName: suiteName) }
-
- defaults.set(true, forKey: WorkspaceAutoReorderSettings.key)
- XCTAssertTrue(WorkspaceAutoReorderSettings.isEnabled(defaults: defaults))
- }
-}
-
-final class LastSurfaceCloseShortcutSettingsTests: XCTestCase {
- func testDefaultClosesWorkspace() {
- let suiteName = "LastSurfaceCloseShortcutSettingsTests.Default.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer { defaults.removePersistentDomain(forName: suiteName) }
-
- XCTAssertTrue(LastSurfaceCloseShortcutSettings.closesWorkspace(defaults: defaults))
- }
-
- func testStoredTrueClosesWorkspace() {
- let suiteName = "LastSurfaceCloseShortcutSettingsTests.Enabled.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer { defaults.removePersistentDomain(forName: suiteName) }
-
- defaults.set(true, forKey: LastSurfaceCloseShortcutSettings.key)
- XCTAssertTrue(LastSurfaceCloseShortcutSettings.closesWorkspace(defaults: defaults))
- }
-
- func testStoredFalseKeepsWorkspaceOpen() {
- let suiteName = "LastSurfaceCloseShortcutSettingsTests.Disabled.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer { defaults.removePersistentDomain(forName: suiteName) }
-
- defaults.set(false, forKey: LastSurfaceCloseShortcutSettings.key)
- XCTAssertFalse(LastSurfaceCloseShortcutSettings.closesWorkspace(defaults: defaults))
- }
-}
-
-final class SidebarBranchLayoutSettingsTests: XCTestCase {
- func testDefaultUsesVerticalLayout() {
- let suiteName = "SidebarBranchLayoutSettingsTests.Default.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer { defaults.removePersistentDomain(forName: suiteName) }
-
- XCTAssertTrue(SidebarBranchLayoutSettings.usesVerticalLayout(defaults: defaults))
- }
-
- func testStoredPreferenceOverridesDefault() {
- let suiteName = "SidebarBranchLayoutSettingsTests.Stored.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer { defaults.removePersistentDomain(forName: suiteName) }
-
- defaults.set(false, forKey: SidebarBranchLayoutSettings.key)
- XCTAssertFalse(SidebarBranchLayoutSettings.usesVerticalLayout(defaults: defaults))
-
- defaults.set(true, forKey: SidebarBranchLayoutSettings.key)
- XCTAssertTrue(SidebarBranchLayoutSettings.usesVerticalLayout(defaults: defaults))
- }
-}
-
-final class SidebarWorkspaceDetailSettingsTests: XCTestCase {
- func testDefaultPreferencesWhenUnset() {
- let suiteName = "SidebarWorkspaceDetailSettingsTests.Default.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer { defaults.removePersistentDomain(forName: suiteName) }
-
- XCTAssertFalse(SidebarWorkspaceDetailSettings.hidesAllDetails(defaults: defaults))
- XCTAssertTrue(SidebarWorkspaceDetailSettings.showsNotificationMessage(defaults: defaults))
- XCTAssertTrue(
- SidebarWorkspaceDetailSettings.resolvedNotificationMessageVisibility(
- showNotificationMessage: SidebarWorkspaceDetailSettings.showsNotificationMessage(defaults: defaults),
- hideAllDetails: SidebarWorkspaceDetailSettings.hidesAllDetails(defaults: defaults)
- )
- )
- }
-
- func testStoredPreferencesOverrideDefaults() {
- let suiteName = "SidebarWorkspaceDetailSettingsTests.Stored.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer { defaults.removePersistentDomain(forName: suiteName) }
-
- defaults.set(true, forKey: SidebarWorkspaceDetailSettings.hideAllDetailsKey)
- defaults.set(false, forKey: SidebarWorkspaceDetailSettings.showNotificationMessageKey)
-
- XCTAssertTrue(SidebarWorkspaceDetailSettings.hidesAllDetails(defaults: defaults))
- XCTAssertFalse(SidebarWorkspaceDetailSettings.showsNotificationMessage(defaults: defaults))
- XCTAssertFalse(
- SidebarWorkspaceDetailSettings.resolvedNotificationMessageVisibility(
- showNotificationMessage: SidebarWorkspaceDetailSettings.showsNotificationMessage(defaults: defaults),
- hideAllDetails: false
- )
- )
- XCTAssertFalse(
- SidebarWorkspaceDetailSettings.resolvedNotificationMessageVisibility(
- showNotificationMessage: true,
- hideAllDetails: SidebarWorkspaceDetailSettings.hidesAllDetails(defaults: defaults)
- )
- )
- }
-}
-
-final class SidebarWorkspaceAuxiliaryDetailVisibilityTests: XCTestCase {
- func testResolvedVisibilityPreservesPerRowTogglesWhenDetailsAreShown() {
- XCTAssertEqual(
- SidebarWorkspaceAuxiliaryDetailVisibility.resolved(
- showMetadata: true,
- showLog: false,
- showProgress: true,
- showBranchDirectory: false,
- showPullRequests: true,
- showPorts: false,
- hideAllDetails: false
- ),
- SidebarWorkspaceAuxiliaryDetailVisibility(
- showsMetadata: true,
- showsLog: false,
- showsProgress: true,
- showsBranchDirectory: false,
- showsPullRequests: true,
- showsPorts: false
- )
- )
- }
-
- func testResolvedVisibilityHidesAllAuxiliaryRowsWhenDetailsAreHidden() {
- XCTAssertEqual(
- SidebarWorkspaceAuxiliaryDetailVisibility.resolved(
- showMetadata: true,
- showLog: true,
- showProgress: true,
- showBranchDirectory: true,
- showPullRequests: true,
- showPorts: true,
- hideAllDetails: true
- ),
- .hidden
- )
- }
-}
-
-final class SidebarActiveTabIndicatorSettingsTests: XCTestCase {
- func testDefaultStyleWhenUnset() {
- let suiteName = "SidebarActiveTabIndicatorSettingsTests.Default.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer { defaults.removePersistentDomain(forName: suiteName) }
-
- defaults.removeObject(forKey: SidebarActiveTabIndicatorSettings.styleKey)
- XCTAssertEqual(
- SidebarActiveTabIndicatorSettings.current(defaults: defaults),
- SidebarActiveTabIndicatorSettings.defaultStyle
- )
- }
-
- func testStoredStyleParsesAndInvalidFallsBack() {
- let suiteName = "SidebarActiveTabIndicatorSettingsTests.Stored.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer { defaults.removePersistentDomain(forName: suiteName) }
-
- defaults.set(SidebarActiveTabIndicatorStyle.leftRail.rawValue, forKey: SidebarActiveTabIndicatorSettings.styleKey)
- XCTAssertEqual(SidebarActiveTabIndicatorSettings.current(defaults: defaults), .leftRail)
-
- defaults.set("rail", forKey: SidebarActiveTabIndicatorSettings.styleKey)
- XCTAssertEqual(SidebarActiveTabIndicatorSettings.current(defaults: defaults), .leftRail)
-
- defaults.set("not-a-style", forKey: SidebarActiveTabIndicatorSettings.styleKey)
- XCTAssertEqual(
- SidebarActiveTabIndicatorSettings.current(defaults: defaults),
- SidebarActiveTabIndicatorSettings.defaultStyle
- )
- }
-}
-
-final class AppearanceSettingsTests: XCTestCase {
- func testResolvedModeDefaultsToSystemWhenUnset() {
- let suiteName = "AppearanceSettingsTests.Default.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer { defaults.removePersistentDomain(forName: suiteName) }
-
- defaults.removeObject(forKey: AppearanceSettings.appearanceModeKey)
-
- let resolved = AppearanceSettings.resolvedMode(defaults: defaults)
- XCTAssertEqual(resolved, .system)
- XCTAssertEqual(defaults.string(forKey: AppearanceSettings.appearanceModeKey), AppearanceMode.system.rawValue)
- }
-}
-
-final class QuitWarningSettingsTests: XCTestCase {
- func testDefaultWarnBeforeQuitIsEnabledWhenUnset() {
- let suiteName = "QuitWarningSettingsTests.Default.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer { defaults.removePersistentDomain(forName: suiteName) }
-
- defaults.removeObject(forKey: QuitWarningSettings.warnBeforeQuitKey)
-
- XCTAssertTrue(QuitWarningSettings.isEnabled(defaults: defaults))
- }
-
- func testStoredPreferenceOverridesDefault() {
- let suiteName = "QuitWarningSettingsTests.Stored.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer { defaults.removePersistentDomain(forName: suiteName) }
-
- defaults.set(false, forKey: QuitWarningSettings.warnBeforeQuitKey)
- XCTAssertFalse(QuitWarningSettings.isEnabled(defaults: defaults))
-
- defaults.set(true, forKey: QuitWarningSettings.warnBeforeQuitKey)
- XCTAssertTrue(QuitWarningSettings.isEnabled(defaults: defaults))
- }
-}
-
-final class UpdateChannelSettingsTests: XCTestCase {
- func testResolvedFeedFallsBackWhenInfoFeedMissing() {
- let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: nil)
- XCTAssertEqual(resolved.url, UpdateFeedResolver.fallbackFeedURL)
- XCTAssertFalse(resolved.isNightly)
- XCTAssertTrue(resolved.usedFallback)
- }
-
- func testResolvedFeedFallsBackWhenInfoFeedEmpty() {
- let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: "")
- XCTAssertEqual(resolved.url, UpdateFeedResolver.fallbackFeedURL)
- XCTAssertFalse(resolved.isNightly)
- XCTAssertTrue(resolved.usedFallback)
- }
-
- func testResolvedFeedUsesInfoFeedForStableChannel() {
- let infoFeed = "https://example.com/custom/appcast.xml"
- let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: infoFeed)
- XCTAssertEqual(resolved.url, infoFeed)
- XCTAssertFalse(resolved.isNightly)
- XCTAssertFalse(resolved.usedFallback)
- }
-
- func testResolvedFeedDetectsNightlyFromInfoFeedURL() {
- let resolved = UpdateFeedResolver.resolvedFeedURLString(
- infoFeedURL: "https://example.com/nightly/appcast.xml"
- )
- XCTAssertEqual(resolved.url, "https://example.com/nightly/appcast.xml")
- XCTAssertTrue(resolved.isNightly)
- XCTAssertFalse(resolved.usedFallback)
- }
-}
-
-final class UpdateSettingsTests: XCTestCase {
- func testApplyEnablesAutomaticChecksAndDailySchedule() {
- let defaults = makeDefaults()
- UpdateSettings.apply(to: defaults)
-
- XCTAssertTrue(defaults.bool(forKey: UpdateSettings.automaticChecksKey))
- XCTAssertEqual(defaults.double(forKey: UpdateSettings.scheduledCheckIntervalKey), UpdateSettings.scheduledCheckInterval)
- XCTAssertFalse(defaults.bool(forKey: UpdateSettings.automaticallyUpdateKey))
- XCTAssertFalse(defaults.bool(forKey: UpdateSettings.sendProfileInfoKey))
- XCTAssertTrue(defaults.bool(forKey: UpdateSettings.migrationKey))
- }
-
- func testApplyRepairsLegacyDisabledAutomaticChecksOnce() {
- let defaults = makeDefaults()
- defaults.set(false, forKey: UpdateSettings.automaticChecksKey)
- defaults.set(0, forKey: UpdateSettings.scheduledCheckIntervalKey)
- defaults.set(true, forKey: UpdateSettings.automaticallyUpdateKey)
-
- UpdateSettings.apply(to: defaults)
-
- XCTAssertTrue(defaults.bool(forKey: UpdateSettings.automaticChecksKey))
- XCTAssertEqual(defaults.double(forKey: UpdateSettings.scheduledCheckIntervalKey), UpdateSettings.scheduledCheckInterval)
- XCTAssertTrue(defaults.bool(forKey: UpdateSettings.automaticallyUpdateKey))
-
- defaults.set(false, forKey: UpdateSettings.automaticChecksKey)
- UpdateSettings.apply(to: defaults)
-
- XCTAssertFalse(defaults.bool(forKey: UpdateSettings.automaticChecksKey))
- }
-
- private func makeDefaults() -> UserDefaults {
- let suiteName = "UpdateSettingsTests.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- fatalError("Failed to create isolated UserDefaults suite")
- }
- defaults.removePersistentDomain(forName: suiteName)
- return defaults
- }
-}
-
-final class SidebarRemoteErrorCopySupportTests: XCTestCase {
- func testMenuLabelIsNilWhenThereAreNoErrors() {
- XCTAssertNil(SidebarRemoteErrorCopySupport.menuLabel(for: []))
- XCTAssertNil(SidebarRemoteErrorCopySupport.clipboardText(for: []))
- }
-
- func testSingleErrorUsesCopyErrorLabelAndSingleLinePayload() {
- let entries = [
- SidebarRemoteErrorCopyEntry(
- workspaceTitle: "alpha",
- target: "devbox:22",
- detail: "failed to start reverse relay"
- )
- ]
-
- XCTAssertEqual(SidebarRemoteErrorCopySupport.menuLabel(for: entries), "Copy Error")
- XCTAssertEqual(
- SidebarRemoteErrorCopySupport.clipboardText(for: entries),
- "SSH error (devbox:22): failed to start reverse relay"
- )
- }
-
- func testMultipleErrorsUseCopyErrorsLabelAndEnumeratedPayload() {
- let entries = [
- SidebarRemoteErrorCopyEntry(
- workspaceTitle: "alpha",
- target: "devbox-a:22",
- detail: "connection timed out"
- ),
- SidebarRemoteErrorCopyEntry(
- workspaceTitle: "beta",
- target: "devbox-b:22",
- detail: "permission denied"
- ),
- ]
-
- XCTAssertEqual(SidebarRemoteErrorCopySupport.menuLabel(for: entries), "Copy Errors")
- XCTAssertEqual(
- SidebarRemoteErrorCopySupport.clipboardText(for: entries),
- """
- 1. alpha (devbox-a:22): connection timed out
- 2. beta (devbox-b:22): permission denied
- """
- )
- }
-
- func testClipboardTextSingleEntryUsesStructuredEntryFields() {
- let entry = SidebarRemoteErrorCopyEntry(
- workspaceTitle: "alpha",
- target: "devbox:22",
- detail: "failed to bootstrap daemon"
- )
- XCTAssertEqual(
- SidebarRemoteErrorCopySupport.clipboardText(for: [entry]),
- "SSH error (devbox:22): failed to bootstrap daemon"
- )
- }
-}
-
-final class WorkspaceReorderTests: XCTestCase {
- @MainActor
- func testReorderWorkspaceMovesWorkspaceToRequestedIndex() {
- let manager = TabManager()
- let first = manager.tabs[0]
- let second = manager.addWorkspace()
- let third = manager.addWorkspace()
-
- manager.selectWorkspace(second)
- XCTAssertEqual(manager.selectedTabId, second.id)
-
- XCTAssertTrue(manager.reorderWorkspace(tabId: second.id, toIndex: 0))
- XCTAssertEqual(manager.tabs.map(\.id), [second.id, first.id, third.id])
- XCTAssertEqual(manager.selectedTabId, second.id)
- }
-
- @MainActor
- func testReorderWorkspaceClampsOutOfRangeTargetIndex() {
- let manager = TabManager()
- let first = manager.tabs[0]
- let second = manager.addWorkspace()
- let third = manager.addWorkspace()
-
- XCTAssertTrue(manager.reorderWorkspace(tabId: first.id, toIndex: 999))
- XCTAssertEqual(manager.tabs.map(\.id), [second.id, third.id, first.id])
- }
-
- @MainActor
- func testReorderWorkspaceReturnsFalseForUnknownWorkspace() {
- let manager = TabManager()
- XCTAssertFalse(manager.reorderWorkspace(tabId: UUID(), toIndex: 0))
- }
-
- @MainActor
- func testReorderWorkspaceKeepsUnpinnedWorkspaceBelowPinnedSegment() {
- let manager = TabManager()
- let firstPinned = manager.tabs[0]
- manager.setPinned(firstPinned, pinned: true)
- let secondPinned = manager.addWorkspace()
- manager.setPinned(secondPinned, pinned: true)
- let unpinned = manager.addWorkspace()
-
- XCTAssertTrue(manager.reorderWorkspace(tabId: unpinned.id, toIndex: 0))
- XCTAssertEqual(manager.tabs.map(\.id), [firstPinned.id, secondPinned.id, unpinned.id])
- }
-
- @MainActor
- func testReorderWorkspaceKeepsPinnedWorkspaceInsidePinnedSegment() {
- let manager = TabManager()
- let firstPinned = manager.tabs[0]
- manager.setPinned(firstPinned, pinned: true)
- let secondPinned = manager.addWorkspace()
- manager.setPinned(secondPinned, pinned: true)
- let unpinned = manager.addWorkspace()
-
- XCTAssertTrue(manager.reorderWorkspace(tabId: firstPinned.id, toIndex: 999))
- XCTAssertEqual(manager.tabs.map(\.id), [secondPinned.id, firstPinned.id, unpinned.id])
- }
-}
-
-@MainActor
-final class WorkspaceNotificationReorderTests: XCTestCase {
- func testNotificationAutoReorderDoesNotMovePinnedWorkspace() {
- let appDelegate = AppDelegate.shared ?? AppDelegate()
- let manager = TabManager()
- let notificationStore = TerminalNotificationStore.shared
-
- let originalTabManager = appDelegate.tabManager
- let originalNotificationStore = appDelegate.notificationStore
- let defaults = UserDefaults.standard
- let originalAutoReorderSetting = defaults.object(forKey: WorkspaceAutoReorderSettings.key)
- let originalAppFocusOverride = AppFocusState.overrideIsFocused
-
- notificationStore.replaceNotificationsForTesting([])
- notificationStore.configureNotificationDeliveryHandlerForTesting { _, _ in }
- appDelegate.tabManager = manager
- appDelegate.notificationStore = notificationStore
- defaults.set(true, forKey: WorkspaceAutoReorderSettings.key)
- AppFocusState.overrideIsFocused = false
-
- defer {
- notificationStore.replaceNotificationsForTesting([])
- notificationStore.resetNotificationDeliveryHandlerForTesting()
- appDelegate.tabManager = originalTabManager
- appDelegate.notificationStore = originalNotificationStore
- AppFocusState.overrideIsFocused = originalAppFocusOverride
- if let originalAutoReorderSetting {
- defaults.set(originalAutoReorderSetting, forKey: WorkspaceAutoReorderSettings.key)
- } else {
- defaults.removeObject(forKey: WorkspaceAutoReorderSettings.key)
- }
- }
-
- let firstPinned = manager.tabs[0]
- manager.setPinned(firstPinned, pinned: true)
- let secondPinned = manager.addWorkspace()
- manager.setPinned(secondPinned, pinned: true)
- let unpinned = manager.addWorkspace()
- let expectedOrder = [firstPinned.id, secondPinned.id, unpinned.id]
-
- notificationStore.addNotification(
- tabId: secondPinned.id,
- surfaceId: nil,
- title: "Build finished",
- subtitle: "",
- body: "Pinned workspaces should stay put"
- )
-
- XCTAssertEqual(manager.tabs.map(\.id), expectedOrder)
- }
-}
-
-@MainActor
-final class TabManagerChildExitCloseTests: XCTestCase {
- func testChildExitOnLastPanelClosesSelectedWorkspaceAndKeepsIndexStable() {
- let manager = TabManager()
- let first = manager.tabs[0]
- let second = manager.addWorkspace()
- let third = manager.addWorkspace()
-
- manager.selectWorkspace(second)
- XCTAssertEqual(manager.selectedTabId, second.id)
-
- guard let secondPanelId = second.focusedPanelId else {
- XCTFail("Expected focused panel in selected workspace")
- return
- }
-
- manager.closePanelAfterChildExited(tabId: second.id, surfaceId: secondPanelId)
-
- XCTAssertEqual(manager.tabs.map(\.id), [first.id, third.id])
- XCTAssertEqual(
- manager.selectedTabId,
- third.id,
- "Expected selection to stay at the same index after deleting the selected workspace"
- )
- }
-
- func testChildExitOnLastPanelInLastWorkspaceSelectsPreviousWorkspace() {
- let manager = TabManager()
- let first = manager.tabs[0]
- let second = manager.addWorkspace()
-
- manager.selectWorkspace(second)
- XCTAssertEqual(manager.selectedTabId, second.id)
-
- guard let secondPanelId = second.focusedPanelId else {
- XCTFail("Expected focused panel in selected workspace")
- return
- }
-
- manager.closePanelAfterChildExited(tabId: second.id, surfaceId: secondPanelId)
-
- XCTAssertEqual(manager.tabs.map(\.id), [first.id])
- XCTAssertEqual(
- manager.selectedTabId,
- first.id,
- "Expected previous workspace to be selected after closing the last-index workspace"
- )
- }
-
- func testChildExitOnNonLastPanelClosesOnlyPanel() {
- let manager = TabManager()
- guard let workspace = manager.selectedWorkspace,
- let initialPanelId = workspace.focusedPanelId else {
- XCTFail("Expected selected workspace with focused panel")
- return
- }
-
- guard let splitPanel = workspace.newTerminalSplit(from: initialPanelId, orientation: .horizontal) else {
- XCTFail("Expected split terminal panel to be created")
- return
- }
-
- let panelCountBefore = workspace.panels.count
- manager.closePanelAfterChildExited(tabId: workspace.id, surfaceId: splitPanel.id)
-
- XCTAssertEqual(manager.tabs.count, 1)
- XCTAssertEqual(manager.tabs.first?.id, workspace.id)
- XCTAssertEqual(workspace.panels.count, panelCountBefore - 1)
- XCTAssertNotNil(workspace.panels[initialPanelId], "Expected sibling panel to remain")
- }
-}
-
-@MainActor
-final class WorkspaceTeardownTests: XCTestCase {
- func testTeardownAllPanelsClearsPanelMetadataCaches() {
- let workspace = Workspace()
- guard let initialPanelId = workspace.focusedPanelId else {
- XCTFail("Expected focused panel in new workspace")
- return
- }
-
- workspace.setPanelCustomTitle(panelId: initialPanelId, title: "Initial custom title")
- workspace.setPanelPinned(panelId: initialPanelId, pinned: true)
-
- guard let splitPanel = workspace.newTerminalSplit(from: initialPanelId, orientation: .horizontal) else {
- XCTFail("Expected split panel to be created")
- return
- }
-
- workspace.setPanelCustomTitle(panelId: splitPanel.id, title: "Split custom title")
- workspace.setPanelPinned(panelId: splitPanel.id, pinned: true)
- workspace.markPanelUnread(initialPanelId)
-
- XCTAssertFalse(workspace.panels.isEmpty)
- XCTAssertFalse(workspace.panelTitles.isEmpty)
- XCTAssertFalse(workspace.panelCustomTitles.isEmpty)
- XCTAssertFalse(workspace.pinnedPanelIds.isEmpty)
- XCTAssertFalse(workspace.manualUnreadPanelIds.isEmpty)
-
- workspace.teardownAllPanels()
-
- XCTAssertTrue(workspace.panels.isEmpty)
- XCTAssertTrue(workspace.panelTitles.isEmpty)
- XCTAssertTrue(workspace.panelCustomTitles.isEmpty)
- XCTAssertTrue(workspace.pinnedPanelIds.isEmpty)
- XCTAssertTrue(workspace.manualUnreadPanelIds.isEmpty)
- }
-}
-
-@MainActor
-final class WorkspaceSplitWorkingDirectoryTests: XCTestCase {
- func testNewTerminalSplitFallsBackToRequestedWorkingDirectoryWhenReportedDirectoryIsStale() {
- let workspace = Workspace()
- guard let sourcePaneId = workspace.bonsplitController.focusedPaneId else {
- XCTFail("Expected focused pane in new workspace")
- return
- }
-
- let staleCurrentDirectory = workspace.currentDirectory
- let requestedDirectory = "/tmp/cmux-requested-split-cwd-\(UUID().uuidString)"
- guard let sourcePanel = workspace.newTerminalSurface(
- inPane: sourcePaneId,
- focus: false,
- workingDirectory: requestedDirectory
- ) else {
- XCTFail("Expected source terminal panel to be created")
- return
- }
-
- XCTAssertEqual(sourcePanel.requestedWorkingDirectory, requestedDirectory)
- XCTAssertNil(
- workspace.panelDirectories[sourcePanel.id],
- "Expected requested cwd to exist before shell integration reports a live cwd"
- )
- XCTAssertEqual(
- workspace.currentDirectory,
- staleCurrentDirectory,
- "Expected focused workspace cwd to remain stale before panel directory updates"
- )
-
- guard let splitPanel = workspace.newTerminalSplit(
- from: sourcePanel.id,
- orientation: .horizontal,
- focus: false
- ) else {
- XCTFail("Expected split terminal panel to be created")
- return
- }
-
- XCTAssertEqual(
- splitPanel.requestedWorkingDirectory,
- requestedDirectory,
- "Expected split to inherit the source terminal's requested cwd when no reported cwd exists yet"
- )
- }
-}
-
-@MainActor
-final class TabManagerWorkspaceOwnershipTests: XCTestCase {
- func testCloseWorkspaceIgnoresWorkspaceNotOwnedByManager() {
- let manager = TabManager()
- _ = manager.addWorkspace()
- let initialTabIds = manager.tabs.map(\.id)
- let initialSelectedTabId = manager.selectedTabId
-
- let externalWorkspace = Workspace(title: "External workspace")
- let externalPanelCountBefore = externalWorkspace.panels.count
- let externalPanelTitlesBefore = externalWorkspace.panelTitles
-
- manager.closeWorkspace(externalWorkspace)
-
- XCTAssertEqual(manager.tabs.map(\.id), initialTabIds)
- XCTAssertEqual(manager.selectedTabId, initialSelectedTabId)
- XCTAssertEqual(externalWorkspace.panels.count, externalPanelCountBefore)
- XCTAssertEqual(externalWorkspace.panelTitles, externalPanelTitlesBefore)
- }
-}
-
-@MainActor
-final class TabManagerCloseWorkspacesWithConfirmationTests: XCTestCase {
- func testCloseWorkspacesWithConfirmationPromptsOnceAndClosesAcceptedWorkspaces() {
- let manager = TabManager()
- let second = manager.addWorkspace()
- let third = manager.addWorkspace()
- manager.setCustomTitle(tabId: manager.tabs[0].id, title: "Alpha")
- manager.setCustomTitle(tabId: second.id, title: "Beta")
- manager.setCustomTitle(tabId: third.id, title: "Gamma")
-
- var prompts: [(title: String, message: String, acceptCmdD: Bool)] = []
- manager.confirmCloseHandler = { title, message, acceptCmdD in
- prompts.append((title, message, acceptCmdD))
- return true
- }
-
- manager.closeWorkspacesWithConfirmation([manager.tabs[0].id, second.id], allowPinned: true)
-
- let expectedMessage = String(
- format: String(
- localized: "dialog.closeWorkspaces.message",
- defaultValue: "This will close %1$lld workspaces and all of their panels:\n%2$@"
- ),
- locale: .current,
- Int64(2),
- "• Alpha\n• Beta"
- )
- XCTAssertEqual(prompts.count, 1, "Expected a single confirmation prompt for multi-close")
- XCTAssertEqual(
- prompts.first?.title,
- String(localized: "dialog.closeWorkspaces.title", defaultValue: "Close workspaces?")
- )
- XCTAssertEqual(prompts.first?.message, expectedMessage)
- XCTAssertEqual(prompts.first?.acceptCmdD, false)
- XCTAssertEqual(manager.tabs.map(\.title), ["Gamma"])
- }
-
- func testCloseWorkspacesWithConfirmationKeepsWorkspacesWhenCancelled() {
- let manager = TabManager()
- let second = manager.addWorkspace()
- manager.setCustomTitle(tabId: manager.tabs[0].id, title: "Alpha")
- manager.setCustomTitle(tabId: second.id, title: "Beta")
-
- var prompts: [(title: String, message: String, acceptCmdD: Bool)] = []
- manager.confirmCloseHandler = { title, message, acceptCmdD in
- prompts.append((title, message, acceptCmdD))
- return false
- }
-
- manager.closeWorkspacesWithConfirmation([manager.tabs[0].id, second.id], allowPinned: true)
-
- let expectedMessage = String(
- format: String(
- localized: "dialog.closeWorkspacesWindow.message",
- defaultValue: "This will close the current window, its %1$lld workspaces, and all of their panels:\n%2$@"
- ),
- locale: .current,
- Int64(2),
- "• Alpha\n• Beta"
- )
- XCTAssertEqual(prompts.count, 1)
- XCTAssertEqual(
- prompts.first?.title,
- String(localized: "dialog.closeWindow.title", defaultValue: "Close window?")
- )
- XCTAssertEqual(prompts.first?.message, expectedMessage)
- XCTAssertEqual(prompts.first?.acceptCmdD, true)
- XCTAssertEqual(manager.tabs.map(\.title), ["Alpha", "Beta"])
- }
-
- func testCloseCurrentWorkspaceWithConfirmationUsesSidebarMultiSelection() {
- let manager = TabManager()
- let second = manager.addWorkspace()
- let third = manager.addWorkspace()
- manager.setCustomTitle(tabId: manager.tabs[0].id, title: "Alpha")
- manager.setCustomTitle(tabId: second.id, title: "Beta")
- manager.setCustomTitle(tabId: third.id, title: "Gamma")
- manager.selectWorkspace(second)
- manager.setSidebarSelectedWorkspaceIds([manager.tabs[0].id, second.id])
-
- var prompts: [(title: String, message: String, acceptCmdD: Bool)] = []
- manager.confirmCloseHandler = { title, message, acceptCmdD in
- prompts.append((title, message, acceptCmdD))
- return false
- }
-
- manager.closeCurrentWorkspaceWithConfirmation()
-
- let expectedMessage = String(
- format: String(
- localized: "dialog.closeWorkspaces.message",
- defaultValue: "This will close %1$lld workspaces and all of their panels:\n%2$@"
- ),
- locale: .current,
- Int64(2),
- "• Alpha\n• Beta"
- )
- XCTAssertEqual(prompts.count, 1, "Expected Cmd+Shift+W path to reuse the multi-close summary dialog")
- XCTAssertEqual(
- prompts.first?.title,
- String(localized: "dialog.closeWorkspaces.title", defaultValue: "Close workspaces?")
- )
- XCTAssertEqual(prompts.first?.message, expectedMessage)
- XCTAssertEqual(prompts.first?.acceptCmdD, false)
- XCTAssertEqual(manager.tabs.map(\.title), ["Alpha", "Beta", "Gamma"])
- }
-}
-
-@MainActor
-final class TabManagerCloseCurrentPanelTests: XCTestCase {
- func testRuntimeCloseSkipsConfirmationWhenShellReportsPromptIdle() {
- let manager = TabManager()
- guard let workspace = manager.selectedWorkspace,
- let panelId = workspace.focusedPanelId,
- let terminalPanel = workspace.terminalPanel(for: panelId) else {
- XCTFail("Expected selected workspace and focused terminal panel")
- return
- }
-
- terminalPanel.surface.setNeedsConfirmCloseOverrideForTesting(true)
- workspace.updatePanelShellActivityState(panelId: panelId, state: .promptIdle)
-
- var promptCount = 0
- manager.confirmCloseHandler = { _, _, _ in
- promptCount += 1
- return false
- }
-
- manager.closeRuntimeSurfaceWithConfirmation(tabId: workspace.id, surfaceId: panelId)
- drainMainQueue()
- drainMainQueue()
-
- XCTAssertEqual(promptCount, 0, "Runtime closes should honor prompt-idle shell state")
- XCTAssertNil(workspace.panels[panelId], "Expected the original panel to close")
- XCTAssertEqual(workspace.panels.count, 1, "Expected a replacement surface after closing the last panel")
- }
-
- func testRuntimeClosePromptsWhenShellReportsRunningCommand() {
- let manager = TabManager()
- guard let workspace = manager.selectedWorkspace,
- let panelId = workspace.focusedPanelId,
- let terminalPanel = workspace.terminalPanel(for: panelId) else {
- XCTFail("Expected selected workspace and focused terminal panel")
- return
- }
-
- terminalPanel.surface.setNeedsConfirmCloseOverrideForTesting(false)
- workspace.updatePanelShellActivityState(panelId: panelId, state: .commandRunning)
-
- var promptCount = 0
- manager.confirmCloseHandler = { _, _, _ in
- promptCount += 1
- return false
- }
-
- manager.closeRuntimeSurfaceWithConfirmation(tabId: workspace.id, surfaceId: panelId)
-
- XCTAssertEqual(promptCount, 1, "Running commands should still require confirmation")
- XCTAssertNotNil(workspace.panels[panelId], "Prompt rejection should keep the original panel open")
- }
-
- func testCloseCurrentPanelClosesWorkspaceWhenItOwnsTheLastSurface() {
- let manager = TabManager()
- let firstWorkspace = manager.tabs[0]
- let secondWorkspace = manager.addWorkspace()
- manager.selectWorkspace(secondWorkspace)
-
- guard let secondPanelId = secondWorkspace.focusedPanelId else {
- XCTFail("Expected focused panel in selected workspace")
- return
- }
-
- XCTAssertEqual(manager.selectedTabId, secondWorkspace.id)
- XCTAssertEqual(secondWorkspace.panels.count, 1)
-
- manager.closeCurrentPanelWithConfirmation()
- drainMainQueue()
- drainMainQueue()
-
- XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id])
- XCTAssertEqual(manager.selectedTabId, firstWorkspace.id)
- XCTAssertNil(secondWorkspace.panels[secondPanelId])
- XCTAssertTrue(secondWorkspace.panels.isEmpty)
- }
-
- func testCloseCurrentPanelKeepsWorkspaceOpenWhenKeepWorkspaceOpenPreferenceIsEnabled() {
- let defaults = UserDefaults.standard
- let originalSetting = defaults.object(forKey: lastSurfaceCloseShortcutDefaultsKey)
- defaults.set(false, forKey: lastSurfaceCloseShortcutDefaultsKey)
- defer {
- if let originalSetting {
- defaults.set(originalSetting, forKey: lastSurfaceCloseShortcutDefaultsKey)
- } else {
- defaults.removeObject(forKey: lastSurfaceCloseShortcutDefaultsKey)
- }
- }
-
- let manager = TabManager()
- guard let workspace = manager.selectedWorkspace,
- let initialPanelId = workspace.focusedPanelId else {
- XCTFail("Expected selected workspace and focused panel")
- return
- }
-
- let initialWorkspaceId = workspace.id
-
- manager.closeCurrentPanelWithConfirmation()
- drainMainQueue()
- drainMainQueue()
-
- XCTAssertEqual(manager.tabs.count, 1)
- XCTAssertEqual(manager.selectedTabId, initialWorkspaceId)
- XCTAssertEqual(manager.tabs.first?.id, initialWorkspaceId)
- XCTAssertNil(workspace.panels[initialPanelId])
- XCTAssertEqual(workspace.panels.count, 1)
- XCTAssertNotEqual(workspace.focusedPanelId, initialPanelId)
- }
-
- func testClosePanelButtonClosesWorkspaceWhenItOwnsTheLastSurface() {
- let manager = TabManager()
- let firstWorkspace = manager.tabs[0]
- let secondWorkspace = manager.addWorkspace()
- manager.selectWorkspace(secondWorkspace)
-
- guard let secondPanelId = secondWorkspace.focusedPanelId else {
- XCTFail("Expected focused panel in selected workspace")
- return
- }
-
- XCTAssertEqual(manager.selectedTabId, secondWorkspace.id)
- XCTAssertEqual(secondWorkspace.panels.count, 1)
-
- guard let secondSurfaceId = secondWorkspace.surfaceIdFromPanelId(secondPanelId) else {
- XCTFail("Expected bonsplit surface ID for focused panel")
- return
- }
-
- secondWorkspace.markExplicitClose(surfaceId: secondSurfaceId)
- XCTAssertFalse(secondWorkspace.closePanel(secondPanelId))
- drainMainQueue()
- drainMainQueue()
-
- XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id])
- XCTAssertEqual(manager.selectedTabId, firstWorkspace.id)
- XCTAssertNil(secondWorkspace.panels[secondPanelId])
- XCTAssertTrue(secondWorkspace.panels.isEmpty)
- }
-
- func testClosePanelButtonStillClosesWorkspaceWhenKeepWorkspaceOpenPreferenceIsEnabled() {
- let defaults = UserDefaults.standard
- let originalSetting = defaults.object(forKey: lastSurfaceCloseShortcutDefaultsKey)
- defaults.set(false, forKey: lastSurfaceCloseShortcutDefaultsKey)
- defer {
- if let originalSetting {
- defaults.set(originalSetting, forKey: lastSurfaceCloseShortcutDefaultsKey)
- } else {
- defaults.removeObject(forKey: lastSurfaceCloseShortcutDefaultsKey)
- }
- }
-
- let manager = TabManager()
- let firstWorkspace = manager.tabs[0]
- let secondWorkspace = manager.addWorkspace()
- manager.selectWorkspace(secondWorkspace)
-
- guard let secondPanelId = secondWorkspace.focusedPanelId else {
- XCTFail("Expected focused panel in selected workspace")
- return
- }
-
- guard let secondSurfaceId = secondWorkspace.surfaceIdFromPanelId(secondPanelId) else {
- XCTFail("Expected bonsplit surface ID for focused panel")
- return
- }
-
- secondWorkspace.markExplicitClose(surfaceId: secondSurfaceId)
- XCTAssertFalse(secondWorkspace.closePanel(secondPanelId))
- drainMainQueue()
- drainMainQueue()
-
- XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id])
- XCTAssertEqual(manager.selectedTabId, firstWorkspace.id)
- XCTAssertNil(secondWorkspace.panels[secondPanelId])
- XCTAssertTrue(secondWorkspace.panels.isEmpty)
- }
-
- func testGenericClosePanelKeepsWorkspaceOpenWithoutExplicitCloseMarker() {
- let manager = TabManager()
- guard let workspace = manager.selectedWorkspace,
- let initialPanelId = workspace.focusedPanelId else {
- XCTFail("Expected selected workspace and focused panel")
- return
- }
-
- let initialWorkspaceId = workspace.id
- XCTAssertEqual(manager.tabs.count, 1)
- XCTAssertEqual(workspace.panels.count, 1)
-
- XCTAssertTrue(workspace.closePanel(initialPanelId))
- drainMainQueue()
- drainMainQueue()
-
- XCTAssertEqual(manager.tabs.count, 1)
- XCTAssertEqual(manager.selectedTabId, initialWorkspaceId)
- XCTAssertEqual(manager.tabs.first?.id, initialWorkspaceId)
- XCTAssertNil(workspace.panels[initialPanelId])
- XCTAssertEqual(workspace.panels.count, 1)
- XCTAssertNotEqual(workspace.focusedPanelId, initialPanelId)
- }
-
- func testCloseCurrentPanelIgnoresStaleSurfaceId() {
- let manager = TabManager()
- let firstWorkspace = manager.tabs[0]
- let secondWorkspace = manager.addWorkspace()
-
- manager.closePanelWithConfirmation(tabId: secondWorkspace.id, surfaceId: UUID())
-
- XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id, secondWorkspace.id])
- }
-
- func testCloseCurrentPanelClearsNotificationsForClosedSurface() {
- let appDelegate = AppDelegate.shared ?? AppDelegate()
- let manager = TabManager()
- let store = TerminalNotificationStore.shared
-
- let originalTabManager = appDelegate.tabManager
- let originalNotificationStore = appDelegate.notificationStore
- store.replaceNotificationsForTesting([])
- store.configureNotificationDeliveryHandlerForTesting { _, _ in }
- appDelegate.tabManager = manager
- appDelegate.notificationStore = store
-
- defer {
- store.replaceNotificationsForTesting([])
- store.resetNotificationDeliveryHandlerForTesting()
- appDelegate.tabManager = originalTabManager
- appDelegate.notificationStore = originalNotificationStore
- }
-
- guard let workspace = manager.selectedWorkspace,
- let initialPanelId = workspace.focusedPanelId else {
- XCTFail("Expected selected workspace and focused panel")
- return
- }
-
- store.addNotification(
- tabId: workspace.id,
- surfaceId: initialPanelId,
- title: "Unread",
- subtitle: "",
- body: ""
- )
- XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: initialPanelId))
-
- manager.closeCurrentPanelWithConfirmation()
- drainMainQueue()
- drainMainQueue()
-
- XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: initialPanelId))
- }
-}
-
-@MainActor
-final class TabManagerNotificationFocusTests: XCTestCase {
- func testFocusTabFromNotificationClearsSplitZoomBeforeFocusingTargetPanel() {
- let manager = TabManager()
- guard let workspace = manager.selectedWorkspace,
- let leftPanelId = workspace.focusedPanelId,
- let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
- XCTFail("Expected split setup to succeed")
- return
- }
-
- workspace.focusPanel(leftPanelId)
- XCTAssertTrue(workspace.toggleSplitZoom(panelId: leftPanelId), "Expected split zoom to enable")
- XCTAssertTrue(workspace.bonsplitController.isSplitZoomed, "Expected workspace to start zoomed")
-
- XCTAssertTrue(manager.focusTabFromNotification(workspace.id, surfaceId: rightPanel.id))
- drainMainQueue()
- drainMainQueue()
-
- XCTAssertFalse(
- workspace.bonsplitController.isSplitZoomed,
- "Expected notification focus to exit split zoom so the target pane becomes visible"
- )
- XCTAssertEqual(workspace.focusedPanelId, rightPanel.id, "Expected notification target panel to be focused")
- }
-
- func testFocusTabFromNotificationReturnsFalseForMissingPanel() {
- let manager = TabManager()
- guard let workspace = manager.selectedWorkspace else {
- XCTFail("Expected selected workspace")
- return
- }
-
- XCTAssertFalse(manager.focusTabFromNotification(workspace.id, surfaceId: UUID()))
- }
-}
-
-@MainActor
-final class TabManagerPendingUnfocusPolicyTests: XCTestCase {
- func testDoesNotUnfocusWhenPendingTabIsCurrentlySelected() {
- let tabId = UUID()
-
- XCTAssertFalse(
- TabManager.shouldUnfocusPendingWorkspace(
- pendingTabId: tabId,
- selectedTabId: tabId
- )
- )
- }
-
- func testUnfocusesWhenPendingTabIsNotSelected() {
- XCTAssertTrue(
- TabManager.shouldUnfocusPendingWorkspace(
- pendingTabId: UUID(),
- selectedTabId: UUID()
- )
- )
- XCTAssertTrue(
- TabManager.shouldUnfocusPendingWorkspace(
- pendingTabId: UUID(),
- selectedTabId: nil
- )
- )
- }
-}
-
-@MainActor
-final class TabManagerSurfaceCreationTests: XCTestCase {
- func testNewSurfaceFocusesCreatedSurface() {
- let manager = TabManager()
- guard let workspace = manager.selectedWorkspace else {
- XCTFail("Expected a selected workspace")
- return
- }
-
- let beforePanels = Set(workspace.panels.keys)
- manager.newSurface()
- let afterPanels = Set(workspace.panels.keys)
-
- let createdPanels = afterPanels.subtracting(beforePanels)
- XCTAssertEqual(createdPanels.count, 1, "Expected one new surface for Cmd+T path")
- guard let createdPanelId = createdPanels.first else { return }
-
- XCTAssertEqual(
- workspace.focusedPanelId,
- createdPanelId,
- "Expected newly created surface to be focused"
- )
- }
-
- func testOpenBrowserInsertAtEndPlacesNewBrowserAtPaneEnd() {
- let manager = TabManager()
- guard let workspace = manager.selectedWorkspace,
- let paneId = workspace.bonsplitController.focusedPaneId else {
- XCTFail("Expected focused workspace and pane")
- return
- }
-
- // Add one extra surface so we verify append-to-end rather than first insert behavior.
- _ = workspace.newTerminalSurface(inPane: paneId, focus: false)
-
- guard let browserPanelId = manager.openBrowser(insertAtEnd: true) else {
- XCTFail("Expected browser panel to be created")
- return
- }
-
- let tabs = workspace.bonsplitController.tabs(inPane: paneId)
- guard let lastSurfaceId = tabs.last?.id else {
- XCTFail("Expected at least one surface in pane")
- return
- }
-
- XCTAssertEqual(
- workspace.panelIdFromSurfaceId(lastSurfaceId),
- browserPanelId,
- "Expected Cmd+Shift+B/Cmd+L open path to append browser surface at end"
- )
- XCTAssertEqual(workspace.focusedPanelId, browserPanelId, "Expected opened browser surface to be focused")
- }
-
- func testOpenBrowserInWorkspaceSplitRightSelectsTargetWorkspaceAndCreatesSplit() {
- let manager = TabManager()
- guard let initialWorkspace = manager.selectedWorkspace else {
- XCTFail("Expected initial selected workspace")
- return
- }
- guard let url = URL(string: "https://example.com/pull/123") else {
- XCTFail("Expected test URL to be valid")
- return
- }
-
- let targetWorkspace = manager.addWorkspace(select: false)
- manager.selectWorkspace(initialWorkspace)
- let initialPaneCount = targetWorkspace.bonsplitController.allPaneIds.count
- let initialPanelCount = targetWorkspace.panels.count
-
- guard let browserPanelId = manager.openBrowser(
- inWorkspace: targetWorkspace.id,
- url: url,
- preferSplitRight: true,
- insertAtEnd: true
- ) else {
- XCTFail("Expected browser panel to be created in target workspace")
- return
- }
-
- XCTAssertEqual(manager.selectedTabId, targetWorkspace.id, "Expected target workspace to become selected")
- XCTAssertEqual(
- targetWorkspace.bonsplitController.allPaneIds.count,
- initialPaneCount + 1,
- "Expected split-right browser open to create a new pane"
- )
- XCTAssertEqual(
- targetWorkspace.panels.count,
- initialPanelCount + 1,
- "Expected browser panel count to increase by one"
- )
- XCTAssertEqual(
- targetWorkspace.focusedPanelId,
- browserPanelId,
- "Expected created browser panel to be focused in target workspace"
- )
- XCTAssertTrue(
- targetWorkspace.panels[browserPanelId] is BrowserPanel,
- "Expected created panel to be a browser panel"
- )
- }
-
- func testOpenBrowserInWorkspaceSplitRightReusesTopRightPaneWhenAlreadySplit() {
- let manager = TabManager()
- guard let workspace = manager.selectedWorkspace,
- let leftPanelId = workspace.focusedPanelId,
- let topRightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal),
- workspace.newTerminalSplit(from: topRightPanel.id, orientation: .vertical) != nil,
- let topRightPaneId = workspace.paneId(forPanelId: topRightPanel.id),
- let url = URL(string: "https://example.com/pull/456") else {
- XCTFail("Expected split setup to succeed")
- return
- }
-
- let initialPaneCount = workspace.bonsplitController.allPaneIds.count
-
- guard let browserPanelId = manager.openBrowser(
- inWorkspace: workspace.id,
- url: url,
- preferSplitRight: true,
- insertAtEnd: true
- ) else {
- XCTFail("Expected browser panel to be created")
- return
- }
-
- XCTAssertEqual(
- workspace.bonsplitController.allPaneIds.count,
- initialPaneCount,
- "Expected split-right browser open to reuse existing panes"
- )
- XCTAssertEqual(
- workspace.paneId(forPanelId: browserPanelId),
- topRightPaneId,
- "Expected browser to open in the top-right pane when multiple splits already exist"
- )
-
- let targetPaneTabs = workspace.bonsplitController.tabs(inPane: topRightPaneId)
- guard let lastSurfaceId = targetPaneTabs.last?.id else {
- XCTFail("Expected top-right pane to contain tabs")
- return
- }
- XCTAssertEqual(
- workspace.panelIdFromSurfaceId(lastSurfaceId),
- browserPanelId,
- "Expected browser surface to be appended at end in the reused top-right pane"
- )
- }
-}
-
-@MainActor
-final class TabManagerEqualizeSplitsTests: XCTestCase {
- func testEqualizeSplitsSetsEverySplitDividerToHalf() {
- let manager = TabManager()
- guard let workspace = manager.selectedWorkspace,
- let leftPanelId = workspace.focusedPanelId,
- let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal),
- workspace.newTerminalSplit(from: rightPanel.id, orientation: .vertical) != nil else {
- XCTFail("Expected nested split setup to succeed")
- return
- }
-
- let initialSplits = splitNodes(in: workspace.bonsplitController.treeSnapshot())
- XCTAssertGreaterThanOrEqual(initialSplits.count, 2, "Expected at least two split nodes in nested layout")
-
- for (index, split) in initialSplits.enumerated() {
- guard let splitId = UUID(uuidString: split.id) else {
- XCTFail("Expected split ID to be a UUID")
- return
- }
- let targetPosition: CGFloat = index.isMultiple(of: 2) ? 0.2 : 0.8
- XCTAssertTrue(
- workspace.bonsplitController.setDividerPosition(targetPosition, forSplit: splitId),
- "Expected to seed divider position for split \(splitId)"
- )
- }
-
- XCTAssertTrue(manager.equalizeSplits(tabId: workspace.id), "Expected equalize splits command to succeed")
-
- let equalizedSplits = splitNodes(in: workspace.bonsplitController.treeSnapshot())
- XCTAssertEqual(equalizedSplits.count, initialSplits.count)
- for split in equalizedSplits {
- XCTAssertEqual(split.dividerPosition, 0.5, accuracy: 0.000_1)
- }
- }
-
- private func splitNodes(in node: ExternalTreeNode) -> [ExternalSplitNode] {
- switch node {
- case .pane:
- return []
- case .split(let split):
- return [split] + splitNodes(in: split.first) + splitNodes(in: split.second)
- }
- }
-}
-
-@MainActor
-final class WorkspaceTerminalFocusRecoveryTests: XCTestCase {
- private func makeWindow() -> NSWindow {
- NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 360, height: 220),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- }
-
- private func makeMouseEvent(
- type: NSEvent.EventType,
- location: NSPoint,
- window: NSWindow
- ) -> NSEvent {
- guard let event = NSEvent.mouseEvent(
- with: type,
- location: location,
- modifierFlags: [],
- timestamp: ProcessInfo.processInfo.systemUptime,
- windowNumber: window.windowNumber,
- context: nil,
- eventNumber: 0,
- clickCount: 1,
- pressure: 1.0
- ) else {
- fatalError("Failed to create \(type) mouse event")
- }
- return event
- }
-
- private func surfaceView(in hostedView: GhosttySurfaceScrollView) -> GhosttyNSView? {
- var stack: [NSView] = [hostedView]
- while let current = stack.popLast() {
- if let surfaceView = current as? GhosttyNSView {
- return surfaceView
- }
- stack.append(contentsOf: current.subviews)
- }
- return nil
- }
-
- func testTerminalFirstResponderConvergesSplitActiveStateWhenSelectionAlreadyMatches() {
- let workspace = Workspace()
- guard let leftPanelId = workspace.focusedPanelId,
- let leftPanel = workspace.terminalPanel(for: leftPanelId),
- let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
- XCTFail("Expected split terminal panels")
- return
- }
-
- XCTAssertEqual(
- workspace.focusedPanelId,
- rightPanel.id,
- "Expected the new split panel to be selected before simulating stale focus state"
- )
-
- // Simulate the split-pane failure mode: Bonsplit already points at the right panel,
- // but the active terminal state is still stale on the left panel.
- leftPanel.surface.setFocus(true)
- leftPanel.hostedView.setActive(true)
- rightPanel.surface.setFocus(false)
- rightPanel.hostedView.setActive(false)
-
- workspace.focusPanel(rightPanel.id, trigger: .terminalFirstResponder)
-
- XCTAssertFalse(
- leftPanel.hostedView.debugRenderStats().isActive,
- "Expected stale left-pane active state to be cleared"
- )
- XCTAssertTrue(
- rightPanel.hostedView.debugRenderStats().isActive,
- "Expected terminal-first-responder recovery to reactivate the selected split pane"
- )
- }
-
- func testTerminalClickRecoversSplitActiveStateWhenFocusCallbackIsSuppressed() {
- let workspace = Workspace()
- guard let leftPanelId = workspace.focusedPanelId,
- let leftPanel = workspace.terminalPanel(for: leftPanelId),
- let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
- XCTFail("Expected split terminal panels")
- return
- }
-
- let window = makeWindow()
- defer { window.orderOut(nil) }
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- leftPanel.hostedView.frame = NSRect(x: 0, y: 0, width: 180, height: 220)
- rightPanel.hostedView.frame = NSRect(x: 180, y: 0, width: 180, height: 220)
- contentView.addSubview(leftPanel.hostedView)
- contentView.addSubview(rightPanel.hostedView)
-
- leftPanel.hostedView.setVisibleInUI(true)
- rightPanel.hostedView.setVisibleInUI(true)
- leftPanel.hostedView.setFocusHandler {
- workspace.focusPanel(leftPanel.id, trigger: .terminalFirstResponder)
- }
- rightPanel.hostedView.setFocusHandler {
- workspace.focusPanel(rightPanel.id, trigger: .terminalFirstResponder)
- }
-
- window.makeKeyAndOrderFront(nil)
- window.displayIfNeeded()
- contentView.layoutSubtreeIfNeeded()
- RunLoop.current.run(until: Date().addingTimeInterval(0.05))
-
- XCTAssertEqual(
- workspace.focusedPanelId,
- rightPanel.id,
- "Expected the clicked split pane to already be selected before simulating stale focus state"
- )
-
- // Simulate the ghost-terminal race: the right pane is selected in Bonsplit, but stale
- // active state remains on the left and the right pane's AppKit focus callback never fires
- // after split reparent/layout churn.
- leftPanel.surface.setFocus(true)
- leftPanel.hostedView.setActive(true)
- rightPanel.surface.setFocus(false)
- rightPanel.hostedView.setActive(false)
- rightPanel.hostedView.suppressReparentFocus()
-
- guard let rightSurfaceView = surfaceView(in: rightPanel.hostedView) else {
- XCTFail("Expected right terminal surface view")
- return
- }
-
- let pointInWindow = rightSurfaceView.convert(NSPoint(x: 24, y: 24), to: nil)
- let event = makeMouseEvent(type: .leftMouseDown, location: pointInWindow, window: window)
- rightSurfaceView.mouseDown(with: event)
- RunLoop.current.run(until: Date().addingTimeInterval(0.05))
-
- XCTAssertFalse(
- leftPanel.hostedView.debugRenderStats().isActive,
- "Expected clicking the selected split pane to clear stale sibling active state even when AppKit focus callbacks are suppressed"
- )
- XCTAssertTrue(
- rightPanel.hostedView.debugRenderStats().isActive,
- "Expected clicking the selected split pane to reactivate terminal input when focus callbacks are suppressed"
- )
- XCTAssertTrue(
- rightPanel.hostedView.isSurfaceViewFirstResponder(),
- "Expected the clicked split pane to become first responder"
- )
- }
-}
-
-@MainActor
-final class WorkspaceTerminalConfigInheritanceSelectionTests: XCTestCase {
- func testPrefersSelectedTerminalInTargetPaneOverFocusedTerminalElsewhere() {
- let manager = TabManager()
- guard let workspace = manager.selectedWorkspace,
- let leftPanelId = workspace.focusedPanelId,
- let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal),
- let leftPaneId = workspace.paneId(forPanelId: leftPanelId) else {
- XCTFail("Expected workspace split setup to succeed")
- return
- }
-
- // Programmatic split focuses the new right panel by default.
- XCTAssertEqual(workspace.focusedPanelId, rightPanel.id)
-
- let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: leftPaneId)
- XCTAssertEqual(
- sourcePanel?.id,
- leftPanelId,
- "Expected inheritance to use the selected terminal in the target pane"
- )
- }
-
- func testFallsBackToAnotherTerminalInPaneWhenSelectedTabIsBrowser() {
- let manager = TabManager()
- guard let workspace = manager.selectedWorkspace,
- let terminalPanelId = workspace.focusedPanelId,
- let paneId = workspace.paneId(forPanelId: terminalPanelId),
- let browserPanel = workspace.newBrowserSurface(inPane: paneId, focus: true) else {
- XCTFail("Expected workspace browser setup to succeed")
- return
- }
-
- XCTAssertEqual(workspace.focusedPanelId, browserPanel.id)
-
- let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: paneId)
- XCTAssertEqual(
- sourcePanel?.id,
- terminalPanelId,
- "Expected inheritance to fall back to a terminal in the pane when browser is selected"
- )
- }
-
- func testPreferredTerminalPanelWinsWhenProvided() {
- let manager = TabManager()
- guard let workspace = manager.selectedWorkspace,
- let terminalPanelId = workspace.focusedPanelId else {
- XCTFail("Expected selected workspace with a terminal panel")
- return
- }
-
- let sourcePanel = workspace.terminalPanelForConfigInheritance(preferredPanelId: terminalPanelId)
- XCTAssertEqual(sourcePanel?.id, terminalPanelId)
- }
-
- func testPrefersLastFocusedTerminalWhenBrowserFocusedInDifferentPane() {
- let manager = TabManager()
- guard let workspace = manager.selectedWorkspace,
- let leftTerminalPanelId = workspace.focusedPanelId,
- let rightTerminalPanel = workspace.newTerminalSplit(from: leftTerminalPanelId, orientation: .horizontal),
- let rightPaneId = workspace.paneId(forPanelId: rightTerminalPanel.id) else {
- XCTFail("Expected split setup to succeed")
- return
- }
-
- workspace.focusPanel(leftTerminalPanelId)
- _ = workspace.newBrowserSurface(inPane: rightPaneId, focus: true)
- XCTAssertNotEqual(workspace.focusedPanelId, leftTerminalPanelId)
-
- let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: rightPaneId)
- XCTAssertEqual(
- sourcePanel?.id,
- leftTerminalPanelId,
- "Expected inheritance to prefer last focused terminal when browser is focused in another pane"
- )
- }
-}
-
-@MainActor
-final class WorkspaceBrowserProfileSelectionTests: XCTestCase {
- private final class RejectingCreateTabDelegate: BonsplitDelegate {
- func splitTabBar(_ controller: BonsplitController, shouldCreateTab tab: Bonsplit.Tab, inPane pane: PaneID) -> Bool {
- false
- }
- }
-
- private final class RejectingSplitPaneDelegate: BonsplitDelegate {
- func splitTabBar(_ controller: BonsplitController, shouldSplitPane pane: PaneID, orientation: SplitOrientation) -> Bool {
- false
- }
- }
-
- func testNewBrowserSurfacePrefersSelectedBrowserProfileInTargetPane() throws {
- let workspace = Workspace()
- let profileA = try makeTemporaryBrowserProfile(named: "Alpha")
- let profileB = try makeTemporaryBrowserProfile(named: "Beta")
- let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId)
- let browserA = try XCTUnwrap(
- workspace.newBrowserSurface(
- inPane: paneId,
- focus: true,
- preferredProfileID: profileA.id
- )
- )
- _ = try XCTUnwrap(
- workspace.newBrowserSplit(
- from: browserA.id,
- orientation: .horizontal,
- preferredProfileID: profileB.id,
- focus: true
- )
- )
-
- XCTAssertEqual(
- workspace.preferredBrowserProfileID,
- profileB.id,
- "Expected workspace preference to drift to the most recently created browser profile"
- )
-
- let leftSurfaceId = try XCTUnwrap(workspace.surfaceIdFromPanelId(browserA.id))
- workspace.bonsplitController.focusPane(paneId)
- workspace.bonsplitController.selectTab(leftSurfaceId)
-
- let created = try XCTUnwrap(
- workspace.newBrowserSurface(
- inPane: paneId,
- focus: false
- )
- )
-
- XCTAssertEqual(
- created.profileID,
- profileA.id,
- "Expected new browser creation to inherit the selected browser profile from the target pane"
- )
- }
-
- func testNewBrowserSurfaceFailureDoesNotMutatePreferredProfile() throws {
- let workspace = Workspace()
- let preferredProfile = try makeTemporaryBrowserProfile(named: "Preferred")
- let unexpectedProfile = try makeTemporaryBrowserProfile(named: "Unexpected")
-
- let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId)
- _ = try XCTUnwrap(
- workspace.newBrowserSurface(
- inPane: paneId,
- focus: false,
- preferredProfileID: preferredProfile.id
- )
- )
- XCTAssertEqual(workspace.preferredBrowserProfileID, preferredProfile.id)
-
- let rejectingDelegate = RejectingCreateTabDelegate()
- workspace.bonsplitController.delegate = rejectingDelegate
- let created = workspace.newBrowserSurface(
- inPane: paneId,
- focus: false,
- preferredProfileID: unexpectedProfile.id
- )
-
- XCTAssertNil(created)
- XCTAssertEqual(
- workspace.preferredBrowserProfileID,
- preferredProfile.id,
- "Expected a failed browser creation to leave the workspace preferred profile unchanged"
- )
- }
-
- func testNewBrowserSplitFailureDoesNotMutatePreferredProfile() throws {
- let workspace = Workspace()
- let preferredProfile = try makeTemporaryBrowserProfile(named: "Preferred")
- let unexpectedProfile = try makeTemporaryBrowserProfile(named: "Unexpected")
-
- let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId)
- let browser = try XCTUnwrap(
- workspace.newBrowserSurface(
- inPane: paneId,
- focus: true,
- preferredProfileID: preferredProfile.id
- )
- )
- XCTAssertEqual(workspace.preferredBrowserProfileID, preferredProfile.id)
-
- let rejectingDelegate = RejectingSplitPaneDelegate()
- workspace.bonsplitController.delegate = rejectingDelegate
- let created = workspace.newBrowserSplit(
- from: browser.id,
- orientation: .horizontal,
- preferredProfileID: unexpectedProfile.id,
- focus: false
- )
-
- XCTAssertNil(created)
- XCTAssertEqual(
- workspace.preferredBrowserProfileID,
- preferredProfile.id,
- "Expected a failed browser split to leave the workspace preferred profile unchanged"
- )
- }
-}
-
-@MainActor
-final class TabManagerWorkspaceConfigInheritanceSourceTests: XCTestCase {
- func testUsesFocusedTerminalWhenTerminalIsFocused() {
- let manager = TabManager()
- guard let workspace = manager.selectedWorkspace,
- let terminalPanelId = workspace.focusedPanelId else {
- XCTFail("Expected selected workspace with focused terminal")
- return
- }
-
- let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource()
- XCTAssertEqual(sourcePanel?.id, terminalPanelId)
- }
-
- func testFallsBackToTerminalWhenBrowserIsFocused() {
- let manager = TabManager()
- guard let workspace = manager.selectedWorkspace,
- let terminalPanelId = workspace.focusedPanelId,
- let paneId = workspace.paneId(forPanelId: terminalPanelId),
- let browserPanel = workspace.newBrowserSurface(inPane: paneId, focus: true) else {
- XCTFail("Expected selected workspace setup to succeed")
- return
- }
-
- XCTAssertEqual(workspace.focusedPanelId, browserPanel.id)
-
- let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource()
- XCTAssertEqual(
- sourcePanel?.id,
- terminalPanelId,
- "Expected new workspace inheritance source to resolve to the pane terminal when browser is focused"
- )
- }
-
- func testPrefersLastFocusedTerminalAcrossPanesWhenBrowserIsFocused() {
- let manager = TabManager()
- guard let workspace = manager.selectedWorkspace,
- let leftTerminalPanelId = workspace.focusedPanelId,
- let rightTerminalPanel = workspace.newTerminalSplit(from: leftTerminalPanelId, orientation: .horizontal),
- let rightPaneId = workspace.paneId(forPanelId: rightTerminalPanel.id) else {
- XCTFail("Expected split setup to succeed")
- return
- }
-
- workspace.focusPanel(leftTerminalPanelId)
- _ = workspace.newBrowserSurface(inPane: rightPaneId, focus: true)
- XCTAssertNotEqual(workspace.focusedPanelId, leftTerminalPanelId)
-
- let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource()
- XCTAssertEqual(
- sourcePanel?.id,
- leftTerminalPanelId,
- "Expected workspace inheritance source to use last focused terminal across panes"
- )
- }
-}
-
-@MainActor
-final class BrowserPanelProfileIsolationTests: XCTestCase {
- func testStaleDidFinishDoesNotRecordVisitIntoSwitchedProfileHistory() throws {
- let alternateProfile = try makeTemporaryBrowserProfile(named: "Switched")
- let defaultStore = BrowserHistoryStore.shared
- let alternateStore = BrowserProfileStore.shared.historyStore(for: alternateProfile.id)
- defaultStore.clearHistory()
- alternateStore.clearHistory()
- defer {
- defaultStore.clearHistory()
- alternateStore.clearHistory()
- }
-
- let panel = BrowserPanel(
- workspaceId: UUID(),
- profileID: BrowserProfileStore.shared.builtInDefaultProfileID
- )
- let staleWebView = panel.webView
- let staleDelegate = try XCTUnwrap(staleWebView.navigationDelegate)
- let staleURL = try XCTUnwrap(URL(string: "https://example.com/stale-finish"))
- staleWebView.loadHTMLString(
- "Stalestale",
- baseURL: staleURL
- )
-
- XCTAssertTrue(
- panel.switchToProfile(alternateProfile.id),
- "Expected profile switch to succeed, current=\(panel.profileID) requested=\(alternateProfile.id) exists=\(BrowserProfileStore.shared.profileDefinition(id: alternateProfile.id) != nil)"
- )
- defaultStore.clearHistory()
- alternateStore.clearHistory()
-
- staleDelegate.webView?(staleWebView, didFinish: nil)
- drainMainQueue()
-
- XCTAssertTrue(
- defaultStore.entries.isEmpty,
- "Expected stale completion callbacks to avoid writing into the old profile history store, found \(defaultStore.entries.map { $0.url })"
- )
- XCTAssertTrue(
- alternateStore.entries.isEmpty,
- "Expected stale completion callbacks to avoid writing into the newly selected profile history store, found \(alternateStore.entries.map { $0.url })"
- )
- }
-}
-
-@MainActor
-final class TabManagerReopenClosedBrowserFocusTests: XCTestCase {
- func testReopenFromDifferentWorkspaceFocusesReopenedBrowser() {
- let manager = TabManager()
- guard let workspace1 = manager.selectedWorkspace,
- let closedBrowserId = manager.openBrowser(url: URL(string: "https://example.com/ws-switch")) else {
- XCTFail("Expected initial workspace and browser panel")
- return
- }
-
- drainMainQueue()
- XCTAssertTrue(workspace1.closePanel(closedBrowserId, force: true))
- drainMainQueue()
-
- let workspace2 = manager.addWorkspace()
- XCTAssertEqual(manager.selectedTabId, workspace2.id)
-
- XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel())
- drainMainQueue()
-
- XCTAssertEqual(manager.selectedTabId, workspace1.id)
- XCTAssertTrue(isFocusedPanelBrowser(in: workspace1))
- }
-
- func testReopenFallsBackToCurrentWorkspaceAndFocusesBrowserWhenOriginalWorkspaceDeleted() {
- let manager = TabManager()
- guard let originalWorkspace = manager.selectedWorkspace,
- let closedBrowserId = manager.openBrowser(url: URL(string: "https://example.com/deleted-ws")) else {
- XCTFail("Expected initial workspace and browser panel")
- return
- }
-
- drainMainQueue()
- XCTAssertTrue(originalWorkspace.closePanel(closedBrowserId, force: true))
- drainMainQueue()
-
- let currentWorkspace = manager.addWorkspace()
- manager.closeWorkspace(originalWorkspace)
-
- XCTAssertEqual(manager.selectedTabId, currentWorkspace.id)
- XCTAssertFalse(manager.tabs.contains(where: { $0.id == originalWorkspace.id }))
-
- XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel())
- drainMainQueue()
-
- XCTAssertEqual(manager.selectedTabId, currentWorkspace.id)
- XCTAssertTrue(isFocusedPanelBrowser(in: currentWorkspace))
- }
-
- func testReopenCollapsedSplitFromDifferentWorkspaceFocusesBrowser() {
- let manager = TabManager()
- guard let workspace1 = manager.selectedWorkspace,
- let sourcePanelId = workspace1.focusedPanelId,
- let splitBrowserId = manager.newBrowserSplit(
- tabId: workspace1.id,
- fromPanelId: sourcePanelId,
- orientation: .horizontal,
- insertFirst: false,
- url: URL(string: "https://example.com/collapsed-split")
- ) else {
- XCTFail("Expected to create browser split")
- return
- }
-
- drainMainQueue()
- XCTAssertTrue(workspace1.closePanel(splitBrowserId, force: true))
- drainMainQueue()
-
- let workspace2 = manager.addWorkspace()
- XCTAssertEqual(manager.selectedTabId, workspace2.id)
-
- XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel())
- drainMainQueue()
-
- XCTAssertEqual(manager.selectedTabId, workspace1.id)
- XCTAssertTrue(isFocusedPanelBrowser(in: workspace1))
- }
-
- func testReopenFromDifferentWorkspaceWinsAgainstSingleDeferredStaleFocus() {
- let manager = TabManager()
- guard let workspace1 = manager.selectedWorkspace,
- let preReopenPanelId = workspace1.focusedPanelId,
- let closedBrowserId = manager.openBrowser(url: URL(string: "https://example.com/stale-focus-cross-ws")) else {
- XCTFail("Expected initial workspace state and browser panel")
- return
- }
-
- drainMainQueue()
- XCTAssertTrue(workspace1.closePanel(closedBrowserId, force: true))
- drainMainQueue()
-
- let panelIdsBeforeReopen = Set(workspace1.panels.keys)
- let workspace2 = manager.addWorkspace()
- XCTAssertEqual(manager.selectedTabId, workspace2.id)
-
- XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel())
- guard let reopenedPanelId = singleNewPanelId(in: workspace1, comparedTo: panelIdsBeforeReopen) else {
- XCTFail("Expected reopened browser panel ID")
- return
- }
-
- // Simulate one delayed stale focus callback from the panel that was focused before reopen.
- DispatchQueue.main.async {
- workspace1.focusPanel(preReopenPanelId)
- }
-
- drainMainQueue()
- drainMainQueue()
- drainMainQueue()
-
- XCTAssertEqual(manager.selectedTabId, workspace1.id)
- XCTAssertEqual(workspace1.focusedPanelId, reopenedPanelId)
- XCTAssertTrue(workspace1.panels[reopenedPanelId] is BrowserPanel)
- }
-
- func testReopenInSameWorkspaceWinsAgainstSingleDeferredStaleFocus() {
- let manager = TabManager()
- guard let workspace = manager.selectedWorkspace,
- let preReopenPanelId = workspace.focusedPanelId,
- let closedBrowserId = manager.openBrowser(url: URL(string: "https://example.com/stale-focus-same-ws")) else {
- XCTFail("Expected initial workspace state and browser panel")
- return
- }
-
- drainMainQueue()
- XCTAssertTrue(workspace.closePanel(closedBrowserId, force: true))
- drainMainQueue()
-
- let panelIdsBeforeReopen = Set(workspace.panels.keys)
- XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel())
- guard let reopenedPanelId = singleNewPanelId(in: workspace, comparedTo: panelIdsBeforeReopen) else {
- XCTFail("Expected reopened browser panel ID")
- return
- }
-
- // Simulate one delayed stale focus callback from the panel that was focused before reopen.
- DispatchQueue.main.async {
- workspace.focusPanel(preReopenPanelId)
- }
-
- drainMainQueue()
- drainMainQueue()
- drainMainQueue()
-
- XCTAssertEqual(manager.selectedTabId, workspace.id)
- XCTAssertEqual(workspace.focusedPanelId, reopenedPanelId)
- XCTAssertTrue(workspace.panels[reopenedPanelId] is BrowserPanel)
- }
-
- private func isFocusedPanelBrowser(in workspace: Workspace) -> Bool {
- guard let focusedPanelId = workspace.focusedPanelId else { return false }
- return workspace.panels[focusedPanelId] is BrowserPanel
- }
-
- private func singleNewPanelId(in workspace: Workspace, comparedTo previousPanelIds: Set) -> UUID? {
- let newPanelIds = Set(workspace.panels.keys).subtracting(previousPanelIds)
- guard newPanelIds.count == 1 else { return nil }
- return newPanelIds.first
- }
-
- private func drainMainQueue() {
- let expectation = expectation(description: "drain main queue")
- DispatchQueue.main.async {
- expectation.fulfill()
- }
- wait(for: [expectation], timeout: 1.0)
- }
-}
-
-@MainActor
-final class WorkspacePanelGitBranchTests: XCTestCase {
- private func drainMainQueue() {
- let expectation = expectation(description: "drain main queue")
- DispatchQueue.main.async {
- expectation.fulfill()
- }
- wait(for: [expectation], timeout: 1.0)
- }
-
- func testBrowserSplitWithFocusFalsePreservesOriginalFocusedPanel() {
- let workspace = Workspace()
- guard let originalFocusedPanelId = workspace.focusedPanelId else {
- XCTFail("Expected initial focused panel")
- return
- }
-
- guard let browserSplitPanel = workspace.newBrowserSplit(
- from: originalFocusedPanelId,
- orientation: .horizontal,
- focus: false
- ) else {
- XCTFail("Expected browser split panel to be created")
- return
- }
-
- drainMainQueue()
-
- XCTAssertNotEqual(browserSplitPanel.id, originalFocusedPanelId)
- XCTAssertEqual(
- workspace.focusedPanelId,
- originalFocusedPanelId,
- "Expected non-focus browser split to preserve pre-split focus"
- )
- }
-
- func testTerminalSplitWithFocusFalsePreservesOriginalFocusedPanel() {
- let workspace = Workspace()
- guard let originalFocusedPanelId = workspace.focusedPanelId else {
- XCTFail("Expected initial focused panel")
- return
- }
-
- guard let terminalSplitPanel = workspace.newTerminalSplit(
- from: originalFocusedPanelId,
- orientation: .horizontal,
- focus: false
- ) else {
- XCTFail("Expected terminal split panel to be created")
- return
- }
-
- drainMainQueue()
-
- XCTAssertNotEqual(terminalSplitPanel.id, originalFocusedPanelId)
- XCTAssertEqual(
- workspace.focusedPanelId,
- originalFocusedPanelId,
- "Expected non-focus terminal split to preserve pre-split focus"
- )
- }
-
- func testDetachLastSurfaceLeavesWorkspaceTemporarilyEmptyForMoveFlow() {
- let workspace = Workspace()
- guard let panelId = workspace.focusedPanelId,
- let paneId = workspace.paneId(forPanelId: panelId) else {
- XCTFail("Expected initial panel and pane")
- return
- }
-
- XCTAssertEqual(workspace.panels.count, 1)
-#if DEBUG
- let baselineFocusReconcileDuringDetach = workspace.debugFocusReconcileScheduledDuringDetachCount
-#endif
-
- guard let detached = workspace.detachSurface(panelId: panelId) else {
- XCTFail("Expected detach of last surface to succeed")
- return
- }
-
- XCTAssertEqual(detached.panelId, panelId)
- XCTAssertTrue(
- workspace.panels.isEmpty,
- "Detaching the last surface should not auto-create a replacement panel"
- )
- XCTAssertNil(workspace.surfaceIdFromPanelId(panelId))
- XCTAssertEqual(workspace.bonsplitController.tabs(inPane: paneId).count, 0)
-
- drainMainQueue()
- drainMainQueue()
-#if DEBUG
- XCTAssertEqual(
- workspace.debugFocusReconcileScheduledDuringDetachCount,
- baselineFocusReconcileDuringDetach,
- "Detaching during cross-workspace moves should not schedule delayed source focus reconciliation"
- )
-#endif
-
- let restoredPanelId = workspace.attachDetachedSurface(detached, inPane: paneId, focus: false)
- XCTAssertEqual(restoredPanelId, panelId)
- XCTAssertEqual(workspace.panels.count, 1)
- }
-
- func testDetachSurfaceWithRemainingPanelsSkipsDelayedFocusReconcile() {
- let workspace = Workspace()
- guard let originalPanelId = workspace.focusedPanelId,
- let movedPanel = workspace.newTerminalSplit(from: originalPanelId, orientation: .horizontal) else {
- XCTFail("Expected two panels before detach")
- return
- }
-
- drainMainQueue()
- drainMainQueue()
-#if DEBUG
- let baselineFocusReconcileDuringDetach = workspace.debugFocusReconcileScheduledDuringDetachCount
-#endif
-
- guard let detached = workspace.detachSurface(panelId: movedPanel.id) else {
- XCTFail("Expected detach to succeed")
- return
- }
-
- XCTAssertEqual(detached.panelId, movedPanel.id)
- XCTAssertEqual(workspace.panels.count, 1, "Expected source workspace to retain only the surviving panel")
- XCTAssertNotNil(workspace.panels[originalPanelId], "Expected the original panel to remain after detach")
-
- drainMainQueue()
- drainMainQueue()
-#if DEBUG
- XCTAssertEqual(
- workspace.debugFocusReconcileScheduledDuringDetachCount,
- baselineFocusReconcileDuringDetach,
- "Detaching into another workspace should not enqueue delayed source focus reconciliation"
- )
-#endif
- }
-
- func testDetachAttachAcrossWorkspacesPreservesNonCustomPanelTitle() {
- let source = Workspace()
- guard let panelId = source.focusedPanelId else {
- XCTFail("Expected source focused panel")
- return
- }
-
- XCTAssertTrue(source.updatePanelTitle(panelId: panelId, title: "detached-runtime-title"))
-
- guard let detached = source.detachSurface(panelId: panelId) else {
- XCTFail("Expected detach to succeed")
- return
- }
-
- XCTAssertEqual(detached.cachedTitle, "detached-runtime-title")
- XCTAssertNil(detached.customTitle)
- XCTAssertEqual(
- detached.title,
- "detached-runtime-title",
- "Detached transfer should carry the cached non-custom title"
- )
-
- let destination = Workspace()
- guard let destinationPane = destination.bonsplitController.allPaneIds.first else {
- XCTFail("Expected destination pane")
- return
- }
-
- let attachedPanelId = destination.attachDetachedSurface(
- detached,
- inPane: destinationPane,
- focus: false
- )
- XCTAssertEqual(attachedPanelId, panelId)
- XCTAssertEqual(destination.panelTitle(panelId: panelId), "detached-runtime-title")
-
- guard let attachedTabId = destination.surfaceIdFromPanelId(panelId),
- let attachedTab = destination.bonsplitController.tab(attachedTabId) else {
- XCTFail("Expected attached tab mapping")
- return
- }
- XCTAssertEqual(attachedTab.title, "detached-runtime-title")
- XCTAssertFalse(attachedTab.hasCustomTitle)
- }
-
- func testBrowserSplitWithFocusFalseRecoversFromDelayedStaleSelection() {
- let workspace = Workspace()
- guard let originalFocusedPanelId = workspace.focusedPanelId else {
- XCTFail("Expected initial focused panel")
- return
- }
- guard let originalPaneId = workspace.paneId(forPanelId: originalFocusedPanelId) else {
- XCTFail("Expected focused pane for initial panel")
- return
- }
-
- guard let browserSplitPanel = workspace.newBrowserSplit(
- from: originalFocusedPanelId,
- orientation: .horizontal,
- focus: false
- ) else {
- XCTFail("Expected browser split panel to be created")
- return
- }
- guard let splitPaneId = workspace.paneId(forPanelId: browserSplitPanel.id),
- let splitTabId = workspace.surfaceIdFromPanelId(browserSplitPanel.id),
- let splitTab = workspace.bonsplitController
- .tabs(inPane: splitPaneId)
- .first(where: { $0.id == splitTabId }) else {
- XCTFail("Expected split pane/tab mapping")
- return
- }
-
- // Simulate one delayed stale split-selection callback from bonsplit.
- DispatchQueue.main.async {
- workspace.splitTabBar(workspace.bonsplitController, didSelectTab: splitTab, inPane: splitPaneId)
- }
-
- drainMainQueue()
- drainMainQueue()
- drainMainQueue()
-
- XCTAssertEqual(
- workspace.focusedPanelId,
- originalFocusedPanelId,
- "Expected non-focus split to reassert the pre-split focused panel"
- )
- XCTAssertEqual(
- workspace.bonsplitController.focusedPaneId,
- originalPaneId,
- "Expected focused pane to converge back to the pre-split pane"
- )
- XCTAssertEqual(
- workspace.bonsplitController.selectedTab(inPane: originalPaneId)?.id,
- workspace.surfaceIdFromPanelId(originalFocusedPanelId),
- "Expected selected tab to converge back to the pre-split focused panel"
- )
- }
-
- func testBrowserSplitWithFocusFalseAllowsSubsequentExplicitFocusOnSplitPanel() {
- let workspace = Workspace()
- guard let originalFocusedPanelId = workspace.focusedPanelId else {
- XCTFail("Expected initial focused panel")
- return
- }
-
- guard let browserSplitPanel = workspace.newBrowserSplit(
- from: originalFocusedPanelId,
- orientation: .horizontal,
- focus: false
- ) else {
- XCTFail("Expected browser split panel to be created")
- return
- }
-
- workspace.focusPanel(browserSplitPanel.id)
-
- drainMainQueue()
- drainMainQueue()
- drainMainQueue()
-
- XCTAssertEqual(
- workspace.focusedPanelId,
- browserSplitPanel.id,
- "Expected explicit focus intent to keep the split panel focused"
- )
- }
-
- func testNewTerminalSurfaceWithFocusFalsePreservesFocusedPanel() {
- let workspace = Workspace()
- guard let originalFocusedPanelId = workspace.focusedPanelId,
- let originalPaneId = workspace.paneId(forPanelId: originalFocusedPanelId) else {
- XCTFail("Expected initial focused panel and pane")
- return
- }
-
- guard let newPanel = workspace.newTerminalSurface(inPane: originalPaneId, focus: false) else {
- XCTFail("Expected terminal surface to be created")
- return
- }
-
- drainMainQueue()
- drainMainQueue()
- drainMainQueue()
-
- XCTAssertNotEqual(newPanel.id, originalFocusedPanelId)
- XCTAssertEqual(
- workspace.focusedPanelId,
- originalFocusedPanelId,
- "Expected non-focus terminal surface creation to preserve the existing focused panel"
- )
- XCTAssertEqual(
- workspace.bonsplitController.selectedTab(inPane: originalPaneId)?.id,
- workspace.surfaceIdFromPanelId(originalFocusedPanelId),
- "Expected selected tab to stay on the original focused panel"
- )
- }
-
- func testNewBrowserSurfaceWithFocusFalsePreservesFocusedPanel() {
- let workspace = Workspace()
- guard let originalFocusedPanelId = workspace.focusedPanelId,
- let originalPaneId = workspace.paneId(forPanelId: originalFocusedPanelId) else {
- XCTFail("Expected initial focused panel and pane")
- return
- }
-
- guard let newPanel = workspace.newBrowserSurface(inPane: originalPaneId, focus: false) else {
- XCTFail("Expected browser surface to be created")
- return
- }
-
- drainMainQueue()
- drainMainQueue()
- drainMainQueue()
-
- XCTAssertNotEqual(newPanel.id, originalFocusedPanelId)
- XCTAssertEqual(
- workspace.focusedPanelId,
- originalFocusedPanelId,
- "Expected non-focus browser surface creation to preserve the existing focused panel"
- )
- XCTAssertEqual(
- workspace.bonsplitController.selectedTab(inPane: originalPaneId)?.id,
- workspace.surfaceIdFromPanelId(originalFocusedPanelId),
- "Expected selected tab to stay on the original focused panel"
- )
- }
-
- func testClosingFocusedSplitRestoresBranchForRemainingFocusedPanel() {
- let workspace = Workspace()
- guard let firstPanelId = workspace.focusedPanelId else {
- XCTFail("Expected initial focused panel")
- return
- }
-
- workspace.updatePanelGitBranch(panelId: firstPanelId, branch: "main", isDirty: false)
- guard let secondPanel = workspace.newTerminalSplit(from: firstPanelId, orientation: .horizontal) else {
- XCTFail("Expected split panel to be created")
- return
- }
-
- workspace.updatePanelGitBranch(panelId: secondPanel.id, branch: "feature/bugfix", isDirty: true)
- XCTAssertEqual(workspace.focusedPanelId, secondPanel.id, "Expected split panel to be focused")
- XCTAssertEqual(workspace.gitBranch?.branch, "feature/bugfix")
- XCTAssertEqual(workspace.gitBranch?.isDirty, true)
-
- XCTAssertTrue(workspace.closePanel(secondPanel.id, force: true), "Expected split panel close to succeed")
- XCTAssertEqual(workspace.focusedPanelId, firstPanelId, "Expected surviving panel to become focused")
- XCTAssertEqual(workspace.gitBranch?.branch, "main")
- XCTAssertEqual(workspace.gitBranch?.isDirty, false)
- }
-
- func testSidebarGitBranchesFollowLeftToRightSplitOrder() {
- let workspace = Workspace()
- guard let leftPanelId = workspace.focusedPanelId else {
- XCTFail("Expected initial focused panel")
- return
- }
-
- workspace.updatePanelGitBranch(panelId: leftPanelId, branch: "main", isDirty: false)
- guard let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
- XCTFail("Expected split panel to be created")
- return
- }
- workspace.updatePanelGitBranch(panelId: rightPanel.id, branch: "feature/sidebar", isDirty: true)
-
- let ordered = workspace.sidebarGitBranchesInDisplayOrder()
- XCTAssertEqual(ordered.map(\.branch), ["main", "feature/sidebar"])
- XCTAssertEqual(ordered.map(\.isDirty), [false, true])
- }
-
- @MainActor
- func testSidebarPullRequestsTrackFocusedPanelOnly() {
- let workspace = Workspace()
- guard let firstPanelId = workspace.focusedPanelId,
- let paneId = workspace.paneId(forPanelId: firstPanelId),
- let secondPanel = workspace.newTerminalSurface(inPane: paneId, focus: false) else {
- XCTFail("Expected focused panel and a second panel")
- return
- }
-
- workspace.updatePanelGitBranch(panelId: firstPanelId, branch: "main", isDirty: false)
- workspace.updatePanelGitBranch(panelId: secondPanel.id, branch: "feature/sidebar-pr", isDirty: false)
- workspace.updatePanelPullRequest(
- panelId: secondPanel.id,
- number: 1629,
- label: "PR",
- url: URL(string: "https://github.com/manaflow-ai/cmux/pull/1629")!,
- status: .open
- )
-
- XCTAssertNil(workspace.pullRequest)
- XCTAssertTrue(
- workspace.sidebarPullRequestsInDisplayOrder().isEmpty,
- "Expected background panel PRs to stay hidden while the focused panel has no PR"
- )
-
- workspace.focusPanel(secondPanel.id)
-
- XCTAssertEqual(
- workspace.sidebarPullRequestsInDisplayOrder().map(\.number),
- [1629]
- )
- }
-
- func testSidebarOrderingUsesPaneOrderThenTabOrderWithBranchDeduping() {
- let workspace = Workspace()
- guard let leftFirstPanelId = workspace.focusedPanelId,
- let leftPaneId = workspace.paneId(forPanelId: leftFirstPanelId),
- let rightFirstPanel = workspace.newTerminalSplit(from: leftFirstPanelId, orientation: .horizontal),
- let rightPaneId = workspace.paneId(forPanelId: rightFirstPanel.id),
- let leftSecondPanel = workspace.newTerminalSurface(inPane: leftPaneId, focus: false),
- let rightSecondPanel = workspace.newTerminalSurface(inPane: rightPaneId, focus: false) else {
- XCTFail("Expected panes and panels for ordering test")
- return
- }
-
- XCTAssertTrue(workspace.reorderSurface(panelId: leftFirstPanelId, toIndex: 0))
- XCTAssertTrue(workspace.reorderSurface(panelId: leftSecondPanel.id, toIndex: 1))
- XCTAssertTrue(workspace.reorderSurface(panelId: rightFirstPanel.id, toIndex: 0))
- XCTAssertTrue(workspace.reorderSurface(panelId: rightSecondPanel.id, toIndex: 1))
-
- workspace.updatePanelGitBranch(panelId: leftFirstPanelId, branch: "main", isDirty: false)
- workspace.updatePanelGitBranch(panelId: leftSecondPanel.id, branch: "feature/left", isDirty: false)
- workspace.updatePanelGitBranch(panelId: rightFirstPanel.id, branch: "main", isDirty: true)
- workspace.updatePanelGitBranch(panelId: rightSecondPanel.id, branch: "feature/right", isDirty: false)
-
- XCTAssertEqual(
- workspace.sidebarOrderedPanelIds(),
- [leftFirstPanelId, leftSecondPanel.id, rightFirstPanel.id, rightSecondPanel.id]
- )
-
- let branches = workspace.sidebarGitBranchesInDisplayOrder()
- XCTAssertEqual(branches.map(\.branch), ["main", "feature/left", "feature/right"])
- XCTAssertEqual(branches.map(\.isDirty), [true, false, false])
- }
-
- func testSidebarDerivedCollectionsMatchWhenUsingPrecomputedPanelOrder() {
- let workspace = Workspace()
- guard let leftFirstPanelId = workspace.focusedPanelId,
- let leftPaneId = workspace.paneId(forPanelId: leftFirstPanelId),
- let rightFirstPanel = workspace.newTerminalSplit(from: leftFirstPanelId, orientation: .horizontal),
- let rightPaneId = workspace.paneId(forPanelId: rightFirstPanel.id),
- let leftSecondPanel = workspace.newTerminalSurface(inPane: leftPaneId, focus: false),
- let rightSecondPanel = workspace.newTerminalSurface(inPane: rightPaneId, focus: false) else {
- XCTFail("Expected panes and panels for precomputed ordering test")
- return
- }
-
- workspace.updatePanelGitBranch(panelId: leftFirstPanelId, branch: "main", isDirty: false)
- workspace.updatePanelGitBranch(panelId: leftSecondPanel.id, branch: "feature/left", isDirty: true)
- workspace.updatePanelGitBranch(panelId: rightFirstPanel.id, branch: "release/right", isDirty: false)
-
- workspace.updatePanelDirectory(panelId: leftFirstPanelId, directory: "/repo/left/root")
- workspace.updatePanelDirectory(panelId: leftSecondPanel.id, directory: "/repo/left/feature")
- workspace.updatePanelDirectory(panelId: rightFirstPanel.id, directory: "/repo/right/root")
- workspace.updatePanelDirectory(panelId: rightSecondPanel.id, directory: "/repo/right/extra")
-
- workspace.updatePanelPullRequest(
- panelId: leftFirstPanelId,
- number: 101,
- label: "PR",
- url: URL(string: "https://github.com/manaflow-ai/cmux/pull/101")!,
- status: .open
- )
- workspace.updatePanelPullRequest(
- panelId: rightFirstPanel.id,
- number: 18,
- label: "MR",
- url: URL(string: "https://gitlab.com/manaflow/cmux/-/merge_requests/18")!,
- status: .merged
- )
-
- let orderedPanelIds = workspace.sidebarOrderedPanelIds()
-
- XCTAssertEqual(
- workspace.sidebarGitBranchesInDisplayOrder(orderedPanelIds: orderedPanelIds).map { "\($0.branch)|\($0.isDirty)" },
- workspace.sidebarGitBranchesInDisplayOrder().map { "\($0.branch)|\($0.isDirty)" }
- )
- XCTAssertEqual(
- workspace.sidebarBranchDirectoryEntriesInDisplayOrder(orderedPanelIds: orderedPanelIds),
- workspace.sidebarBranchDirectoryEntriesInDisplayOrder()
- )
- XCTAssertEqual(
- workspace.sidebarPullRequestsInDisplayOrder(orderedPanelIds: orderedPanelIds),
- workspace.sidebarPullRequestsInDisplayOrder()
- )
- }
-
- func testClosingPaneDropsBranchesFromClosedSide() {
- let workspace = Workspace()
- guard let leftPanelId = workspace.focusedPanelId,
- let leftPaneId = workspace.paneId(forPanelId: leftPanelId),
- let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
- XCTFail("Expected left/right split panes")
- return
- }
-
- workspace.updatePanelGitBranch(panelId: leftPanelId, branch: "branch1", isDirty: false)
- workspace.updatePanelGitBranch(panelId: rightPanel.id, branch: "branch2", isDirty: false)
-
- XCTAssertEqual(workspace.sidebarGitBranchesInDisplayOrder().map(\.branch), ["branch1", "branch2"])
- XCTAssertTrue(workspace.bonsplitController.closePane(leftPaneId))
- XCTAssertEqual(workspace.sidebarGitBranchesInDisplayOrder().map(\.branch), ["branch2"])
- }
-}
-
-final class SidebarBranchOrderingTests: XCTestCase {
-
- func testOrderedUniqueBranchesDedupesByNameAndMergesDirtyState() {
- let first = UUID()
- let second = UUID()
- let third = UUID()
-
- let branches = SidebarBranchOrdering.orderedUniqueBranches(
- orderedPanelIds: [first, second, third],
- panelBranches: [
- first: SidebarGitBranchState(branch: "main", isDirty: false),
- second: SidebarGitBranchState(branch: "feature", isDirty: false),
- third: SidebarGitBranchState(branch: "main", isDirty: true)
- ],
- fallbackBranch: SidebarGitBranchState(branch: "fallback", isDirty: false)
- )
-
- XCTAssertEqual(
- branches,
- [
- SidebarBranchOrdering.BranchEntry(name: "main", isDirty: true),
- SidebarBranchOrdering.BranchEntry(name: "feature", isDirty: false)
- ]
- )
- }
-
- func testOrderedUniqueBranchesUsesFallbackWhenNoPanelBranchesExist() {
- let branches = SidebarBranchOrdering.orderedUniqueBranches(
- orderedPanelIds: [],
- panelBranches: [:],
- fallbackBranch: SidebarGitBranchState(branch: "fallback", isDirty: true)
- )
-
- XCTAssertEqual(
- branches,
- [SidebarBranchOrdering.BranchEntry(name: "fallback", isDirty: true)]
- )
- }
-
- func testOrderedUniqueBranchDirectoryEntriesDedupesPairsAndMergesDirtyState() {
- let first = UUID()
- let second = UUID()
- let third = UUID()
- let fourth = UUID()
- let fifth = UUID()
-
- let rows = SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries(
- orderedPanelIds: [first, second, third, fourth, fifth],
- panelBranches: [
- first: SidebarGitBranchState(branch: "main", isDirty: false),
- second: SidebarGitBranchState(branch: "feature", isDirty: false),
- third: SidebarGitBranchState(branch: "main", isDirty: true),
- fourth: SidebarGitBranchState(branch: "main", isDirty: false)
- ],
- panelDirectories: [
- first: "/repo/a",
- second: "/repo/b",
- third: "/repo/a",
- fourth: "/repo/d",
- fifth: "/repo/e"
- ],
- defaultDirectory: "/repo/default",
- fallbackBranch: SidebarGitBranchState(branch: "fallback", isDirty: false)
- )
-
- XCTAssertEqual(
- rows,
- [
- SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: true, directory: "/repo/a"),
- SidebarBranchOrdering.BranchDirectoryEntry(branch: "feature", isDirty: false, directory: "/repo/b"),
- SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: false, directory: "/repo/d"),
- SidebarBranchOrdering.BranchDirectoryEntry(branch: nil, isDirty: false, directory: "/repo/e")
- ]
- )
- }
-
- func testOrderedUniqueBranchDirectoryEntriesUsesFallbackBranchWhenPanelBranchesMissing() {
- let first = UUID()
- let second = UUID()
-
- let rows = SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries(
- orderedPanelIds: [first, second],
- panelBranches: [:],
- panelDirectories: [
- first: "/repo/one",
- second: "/repo/two"
- ],
- defaultDirectory: "/repo/default",
- fallbackBranch: SidebarGitBranchState(branch: "main", isDirty: true)
- )
-
- XCTAssertEqual(
- rows,
- [
- SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: true, directory: "/repo/one"),
- SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: true, directory: "/repo/two")
- ]
- )
- }
-
- func testOrderedUniqueBranchDirectoryEntriesFallsBackWhenNoPanelsExist() {
- let rows = SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries(
- orderedPanelIds: [],
- panelBranches: [:],
- panelDirectories: [:],
- defaultDirectory: "/repo/default",
- fallbackBranch: SidebarGitBranchState(branch: "main", isDirty: false)
- )
-
- XCTAssertEqual(
- rows,
- [SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: false, directory: "/repo/default")]
- )
- }
-
- func testOrderedUniquePullRequestsFollowsPanelOrderAcrossSplitsAndTabs() {
- let first = UUID()
- let second = UUID()
- let third = UUID()
- let fourth = UUID()
-
- let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests(
- orderedPanelIds: [first, second, third, fourth],
- panelPullRequests: [
- first: pullRequestState(
- number: 337,
- label: "PR",
- url: "https://github.com/manaflow-ai/cmux/pull/337",
- status: .open
- ),
- second: pullRequestState(
- number: 18,
- label: "MR",
- url: "https://gitlab.com/manaflow/cmux/-/merge_requests/18",
- status: .open
- ),
- third: pullRequestState(
- number: 337,
- label: "PR",
- url: "https://github.com/manaflow-ai/cmux/pull/337",
- status: .merged
- ),
- fourth: pullRequestState(
- number: 92,
- label: "PR",
- url: "https://bitbucket.org/manaflow/cmux/pull-requests/92",
- status: .closed
- )
- ],
- fallbackPullRequest: pullRequestState(
- number: 1,
- label: "PR",
- url: "https://example.invalid/fallback/1",
- status: .open
- )
- )
-
- XCTAssertEqual(
- pullRequests.map { "\($0.label)#\($0.number)" },
- ["PR#337", "MR#18", "PR#92"]
- )
- XCTAssertEqual(
- pullRequests.map(\.status),
- [.merged, .open, .closed]
- )
- }
-
- func testOrderedUniquePullRequestsTreatsSameNumberDifferentLabelsAsDistinct() {
- let first = UUID()
- let second = UUID()
-
- let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests(
- orderedPanelIds: [first, second],
- panelPullRequests: [
- first: pullRequestState(
- number: 42,
- label: "PR",
- url: "https://github.com/manaflow-ai/cmux/pull/42",
- status: .open
- ),
- second: pullRequestState(
- number: 42,
- label: "MR",
- url: "https://gitlab.com/manaflow/cmux/-/merge_requests/42",
- status: .open
- )
- ],
- fallbackPullRequest: nil
- )
-
- XCTAssertEqual(
- pullRequests.map { "\($0.label)#\($0.number)" },
- ["PR#42", "MR#42"]
- )
- }
-
- func testOrderedUniquePullRequestsTreatsSameNumberAndLabelDifferentUrlsAsDistinct() {
- let first = UUID()
- let second = UUID()
-
- let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests(
- orderedPanelIds: [first, second],
- panelPullRequests: [
- first: pullRequestState(
- number: 42,
- label: "PR",
- url: "https://github.com/manaflow-ai/cmux/pull/42",
- status: .open
- ),
- second: pullRequestState(
- number: 42,
- label: "PR",
- url: "https://github.com/manaflow-ai/other-repo/pull/42",
- status: .open
- )
- ],
- fallbackPullRequest: nil
- )
-
- XCTAssertEqual(
- pullRequests.map(\.url.absoluteString),
- [
- "https://github.com/manaflow-ai/cmux/pull/42",
- "https://github.com/manaflow-ai/other-repo/pull/42"
- ]
- )
- }
-
- func testOrderedUniquePullRequestsPrefersEntryWithChecksWhenStatusesMatch() {
- let first = UUID()
- let second = UUID()
-
- let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests(
- orderedPanelIds: [first, second],
- panelPullRequests: [
- first: pullRequestState(
- number: 42,
- label: "PR",
- url: "https://github.com/manaflow-ai/cmux/pull/42",
- status: .open
- ),
- second: pullRequestState(
- number: 42,
- label: "PR",
- url: "https://github.com/manaflow-ai/cmux/pull/42",
- status: .open,
- checks: .pass
- )
- ],
- fallbackPullRequest: nil
- )
-
- XCTAssertEqual(pullRequests.count, 1)
- XCTAssertEqual(pullRequests.first?.checks, .pass)
- }
-
- @MainActor
- func testUpdatePanelPullRequestPreservesExistingChecksWhenUpdateOmitsThem() {
- let workspace = Workspace(title: "Tests", workingDirectory: FileManager.default.currentDirectoryPath, portOrdinal: 0)
- guard let panelId = workspace.focusedPanelId else {
- XCTFail("Expected focused panel for new workspace")
- return
- }
-
- workspace.updatePanelPullRequest(
- panelId: panelId,
- number: 42,
- label: "PR",
- url: URL(string: "https://github.com/manaflow-ai/cmux/pull/42")!,
- status: .open,
- checks: .pass
- )
- workspace.updatePanelPullRequest(
- panelId: panelId,
- number: 42,
- label: "PR",
- url: URL(string: "https://github.com/manaflow-ai/cmux/pull/42")!,
- status: .open
- )
-
- XCTAssertEqual(workspace.panelPullRequests[panelId]?.checks, .pass)
- XCTAssertEqual(workspace.pullRequest?.checks, .pass)
- }
-
- func testOrderedUniquePullRequestsUsesFallbackWhenNoPanelPullRequestsExist() {
- let fallback = pullRequestState(
- number: 11,
- label: "PR",
- url: "https://github.com/manaflow-ai/cmux/pull/11",
- status: .open
- )
- let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests(
- orderedPanelIds: [],
- panelPullRequests: [:],
- fallbackPullRequest: fallback
- )
-
- XCTAssertEqual(pullRequests, [fallback])
- }
-
- @MainActor
- func testUpdatePanelGitBranchClearsFocusedPullRequestWhenBranchChanges() {
- let workspace = Workspace(title: "Tests", workingDirectory: FileManager.default.currentDirectoryPath, portOrdinal: 0)
- guard let panelId = workspace.focusedPanelId else {
- XCTFail("Expected focused panel for new workspace")
- return
- }
-
- workspace.updatePanelGitBranch(panelId: panelId, branch: "feature/sidebar-pr", isDirty: false)
- workspace.updatePanelPullRequest(
- panelId: panelId,
- number: 1629,
- label: "PR",
- url: URL(string: "https://github.com/manaflow-ai/cmux/pull/1629")!,
- status: .open
- )
-
- workspace.updatePanelGitBranch(panelId: panelId, branch: "main", isDirty: false)
-
- XCTAssertNil(workspace.pullRequest)
- XCTAssertNil(workspace.panelPullRequests[panelId])
- XCTAssertTrue(workspace.sidebarPullRequestsInDisplayOrder().isEmpty)
- }
-
- @MainActor
- func testSidebarPullRequestsHideBranchMismatches() {
- let workspace = Workspace(title: "Tests", workingDirectory: FileManager.default.currentDirectoryPath, portOrdinal: 0)
- guard let panelId = workspace.focusedPanelId else {
- XCTFail("Expected focused panel for new workspace")
- return
- }
-
- workspace.updatePanelGitBranch(panelId: panelId, branch: "main", isDirty: false)
- workspace.updatePanelPullRequest(
- panelId: panelId,
- number: 1629,
- label: "PR",
- url: URL(string: "https://github.com/manaflow-ai/cmux/pull/1629")!,
- status: .open,
- branch: "feature/sidebar-pr"
- )
-
- XCTAssertTrue(workspace.sidebarPullRequestsInDisplayOrder().isEmpty)
- }
-
- private func pullRequestState(
- number: Int,
- label: String,
- url: String,
- status: SidebarPullRequestStatus,
- branch: String? = nil,
- checks: SidebarPullRequestChecksStatus? = nil
- ) -> SidebarPullRequestState {
- SidebarPullRequestState(
- number: number,
- label: label,
- url: URL(string: url)!,
- status: status,
- branch: branch,
- checks: checks
- )
- }
-}
-
-@MainActor
-final class BrowserPanelAddressBarFocusRequestTests: XCTestCase {
- func testRequestPersistsUntilAcknowledged() {
- let panel = BrowserPanel(workspaceId: UUID())
- XCTAssertNil(panel.pendingAddressBarFocusRequestId)
-
- let requestId = panel.requestAddressBarFocus()
- XCTAssertEqual(panel.pendingAddressBarFocusRequestId, requestId)
- XCTAssertTrue(panel.shouldSuppressWebViewFocus())
-
- panel.acknowledgeAddressBarFocusRequest(requestId)
- XCTAssertNil(panel.pendingAddressBarFocusRequestId)
-
- // Acknowledgement only clears the durable request; focus suppression follows
- // explicit blur state transitions.
- XCTAssertTrue(panel.shouldSuppressWebViewFocus())
- panel.endSuppressWebViewFocusForAddressBar()
- XCTAssertFalse(panel.shouldSuppressWebViewFocus())
- }
-
- func testRequestCoalescesWhilePending() {
- let panel = BrowserPanel(workspaceId: UUID())
- let firstRequest = panel.requestAddressBarFocus()
- let secondRequest = panel.requestAddressBarFocus()
-
- XCTAssertEqual(firstRequest, secondRequest)
- XCTAssertEqual(panel.pendingAddressBarFocusRequestId, firstRequest)
- }
-
- func testStaleAcknowledgementDoesNotClearNewestRequest() {
- let panel = BrowserPanel(workspaceId: UUID())
- let firstRequest = panel.requestAddressBarFocus()
- panel.acknowledgeAddressBarFocusRequest(firstRequest)
- let secondRequest = panel.requestAddressBarFocus()
-
- XCTAssertNotEqual(firstRequest, secondRequest)
- XCTAssertEqual(panel.pendingAddressBarFocusRequestId, secondRequest)
-
- panel.acknowledgeAddressBarFocusRequest(firstRequest)
- XCTAssertEqual(panel.pendingAddressBarFocusRequestId, secondRequest)
-
- panel.acknowledgeAddressBarFocusRequest(secondRequest)
- XCTAssertNil(panel.pendingAddressBarFocusRequestId)
- }
-}
-
-final class SidebarDropPlannerTests: XCTestCase {
- func testNoIndicatorForNoOpEdges() {
- let first = UUID()
- let second = UUID()
- let third = UUID()
- let tabIds = [first, second, third]
-
- XCTAssertNil(
- SidebarDropPlanner.indicator(
- draggedTabId: first,
- targetTabId: first,
- tabIds: tabIds,
- pinnedTabIds: []
- )
- )
- XCTAssertNil(
- SidebarDropPlanner.indicator(
- draggedTabId: third,
- targetTabId: nil,
- tabIds: tabIds,
- pinnedTabIds: []
- )
- )
- }
-
- func testNoIndicatorWhenOnlyOneTabExists() {
- let only = UUID()
- XCTAssertNil(
- SidebarDropPlanner.indicator(
- draggedTabId: only,
- targetTabId: nil,
- tabIds: [only],
- pinnedTabIds: []
- )
- )
- XCTAssertNil(
- SidebarDropPlanner.indicator(
- draggedTabId: only,
- targetTabId: only,
- tabIds: [only],
- pinnedTabIds: []
- )
- )
- }
-
- func testIndicatorAppearsForRealMoveToEnd() {
- let first = UUID()
- let second = UUID()
- let third = UUID()
- let tabIds = [first, second, third]
-
- let indicator = SidebarDropPlanner.indicator(
- draggedTabId: second,
- targetTabId: nil,
- tabIds: tabIds,
- pinnedTabIds: []
- )
- XCTAssertEqual(indicator?.tabId, nil)
- XCTAssertEqual(indicator?.edge, .bottom)
- }
-
- func testTargetIndexForMoveToEndFromMiddle() {
- let first = UUID()
- let second = UUID()
- let third = UUID()
- let tabIds = [first, second, third]
-
- let index = SidebarDropPlanner.targetIndex(
- draggedTabId: second,
- targetTabId: nil,
- indicator: SidebarDropIndicator(tabId: nil, edge: .bottom),
- tabIds: tabIds,
- pinnedTabIds: []
- )
- XCTAssertEqual(index, 2)
- }
-
- func testNoIndicatorForSelfDropInMiddle() {
- let first = UUID()
- let second = UUID()
- let third = UUID()
- let tabIds = [first, second, third]
-
- XCTAssertNil(
- SidebarDropPlanner.indicator(
- draggedTabId: second,
- targetTabId: second,
- tabIds: tabIds,
- pinnedTabIds: []
- )
- )
- }
-
- func testPointerEdgeTopCanSuppressNoOpWhenDraggingFirstOverSecond() {
- let first = UUID()
- let second = UUID()
- let third = UUID()
- let tabIds = [first, second, third]
-
- XCTAssertNil(
- SidebarDropPlanner.indicator(
- draggedTabId: first,
- targetTabId: second,
- tabIds: tabIds,
- pinnedTabIds: [],
- pointerY: 2,
- targetHeight: 40
- )
- )
- }
-
- func testPointerEdgeBottomAllowsMoveWhenDraggingFirstOverSecond() {
- let first = UUID()
- let second = UUID()
- let third = UUID()
- let tabIds = [first, second, third]
-
- let indicator = SidebarDropPlanner.indicator(
- draggedTabId: first,
- targetTabId: second,
- tabIds: tabIds,
- pinnedTabIds: [],
- pointerY: 38,
- targetHeight: 40
- )
- XCTAssertEqual(indicator?.tabId, third)
- XCTAssertEqual(indicator?.edge, .top)
- XCTAssertEqual(
- SidebarDropPlanner.targetIndex(
- draggedTabId: first,
- targetTabId: second,
- indicator: indicator,
- tabIds: tabIds,
- pinnedTabIds: []
- ),
- 1
- )
- }
-
- func testEquivalentBoundaryInputsResolveToSingleCanonicalIndicator() {
- let first = UUID()
- let second = UUID()
- let third = UUID()
- let tabIds = [first, second, third]
-
- let fromBottomOfFirst = SidebarDropPlanner.indicator(
- draggedTabId: third,
- targetTabId: first,
- tabIds: tabIds,
- pinnedTabIds: [],
- pointerY: 38,
- targetHeight: 40
- )
- let fromTopOfSecond = SidebarDropPlanner.indicator(
- draggedTabId: third,
- targetTabId: second,
- tabIds: tabIds,
- pinnedTabIds: [],
- pointerY: 2,
- targetHeight: 40
- )
-
- XCTAssertEqual(fromBottomOfFirst?.tabId, second)
- XCTAssertEqual(fromBottomOfFirst?.edge, .top)
- XCTAssertEqual(fromTopOfSecond?.tabId, second)
- XCTAssertEqual(fromTopOfSecond?.edge, .top)
- }
-
- func testPointerEdgeBottomSuppressesNoOpWhenDraggingLastOverSecond() {
- let first = UUID()
- let second = UUID()
- let third = UUID()
- let tabIds = [first, second, third]
-
- XCTAssertNil(
- SidebarDropPlanner.indicator(
- draggedTabId: third,
- targetTabId: second,
- tabIds: tabIds,
- pinnedTabIds: [],
- pointerY: 38,
- targetHeight: 40
- )
- )
- }
-
- func testIndicatorSnapsUnpinnedDropToFirstUnpinnedBoundaryWhenHoveringPinnedWorkspace() {
- let pinnedA = UUID()
- let pinnedB = UUID()
- let unpinnedA = UUID()
- let unpinnedB = UUID()
- let tabIds = [pinnedA, pinnedB, unpinnedA, unpinnedB]
- let pinnedIds: Set = [pinnedA, pinnedB]
-
- let indicator = SidebarDropPlanner.indicator(
- draggedTabId: unpinnedB,
- targetTabId: pinnedA,
- tabIds: tabIds,
- pinnedTabIds: pinnedIds,
- pointerY: 2,
- targetHeight: 40
- )
-
- XCTAssertEqual(indicator?.tabId, unpinnedA)
- XCTAssertEqual(indicator?.edge, .top)
- }
-
- func testTargetIndexSnapsUnpinnedDropToFirstUnpinnedBoundaryWhenHoveringPinnedWorkspace() {
- let pinnedA = UUID()
- let pinnedB = UUID()
- let unpinnedA = UUID()
- let unpinnedB = UUID()
- let tabIds = [pinnedA, pinnedB, unpinnedA, unpinnedB]
- let pinnedIds: Set = [pinnedA, pinnedB]
-
- let targetIndex = SidebarDropPlanner.targetIndex(
- draggedTabId: unpinnedB,
- targetTabId: pinnedA,
- indicator: SidebarDropIndicator(tabId: pinnedA, edge: .top),
- tabIds: tabIds,
- pinnedTabIds: pinnedIds
- )
-
- XCTAssertEqual(targetIndex, 2)
- }
-
-}
-
-final class SidebarDragAutoScrollPlannerTests: XCTestCase {
- func testAutoScrollPlanTriggersNearTopAndBottomOnly() {
- let topPlan = SidebarDragAutoScrollPlanner.plan(distanceToTop: 4, distanceToBottom: 96, edgeInset: 44, minStep: 2, maxStep: 12)
- XCTAssertEqual(topPlan?.direction, .up)
- XCTAssertNotNil(topPlan)
-
- let bottomPlan = SidebarDragAutoScrollPlanner.plan(distanceToTop: 96, distanceToBottom: 4, edgeInset: 44, minStep: 2, maxStep: 12)
- XCTAssertEqual(bottomPlan?.direction, .down)
- XCTAssertNotNil(bottomPlan)
-
- XCTAssertNil(
- SidebarDragAutoScrollPlanner.plan(distanceToTop: 60, distanceToBottom: 60, edgeInset: 44, minStep: 2, maxStep: 12)
- )
- }
-
- func testAutoScrollPlanSpeedsUpCloserToEdge() {
- let nearTop = SidebarDragAutoScrollPlanner.plan(distanceToTop: 1, distanceToBottom: 99, edgeInset: 44, minStep: 2, maxStep: 12)
- let midTop = SidebarDragAutoScrollPlanner.plan(distanceToTop: 22, distanceToBottom: 78, edgeInset: 44, minStep: 2, maxStep: 12)
-
- XCTAssertNotNil(nearTop)
- XCTAssertNotNil(midTop)
- XCTAssertGreaterThan(nearTop?.pointsPerTick ?? 0, midTop?.pointsPerTick ?? 0)
- }
-
- func testAutoScrollPlanStillTriggersWhenPointerIsPastEdge() {
- let aboveTop = SidebarDragAutoScrollPlanner.plan(distanceToTop: -500, distanceToBottom: 600, edgeInset: 44, minStep: 2, maxStep: 12)
- XCTAssertEqual(aboveTop?.direction, .up)
- XCTAssertEqual(aboveTop?.pointsPerTick, 12)
-
- let belowBottom = SidebarDragAutoScrollPlanner.plan(distanceToTop: 600, distanceToBottom: -500, edgeInset: 44, minStep: 2, maxStep: 12)
- XCTAssertEqual(belowBottom?.direction, .down)
- XCTAssertEqual(belowBottom?.pointsPerTick, 12)
- }
-}
-
-final class FinderServicePathResolverTests: XCTestCase {
- func testOrderedUniqueDirectoriesUsesParentForFilesAndDedupes() {
- let input: [URL] = [
- URL(fileURLWithPath: "/tmp/cmux-services/project", isDirectory: true),
- URL(fileURLWithPath: "/tmp/cmux-services/project/README.md", isDirectory: false),
- URL(fileURLWithPath: "/tmp/cmux-services/../cmux-services/project", isDirectory: true),
- URL(fileURLWithPath: "/tmp/cmux-services/other", isDirectory: true),
- ]
-
- let directories = FinderServicePathResolver.orderedUniqueDirectories(from: input)
- XCTAssertEqual(
- directories,
- [
- "/tmp/cmux-services/project",
- "/tmp/cmux-services/other",
- ]
- )
- }
-
- func testOrderedUniqueDirectoriesPreservesFirstSeenOrder() {
- let input: [URL] = [
- URL(fileURLWithPath: "/tmp/cmux-services/b", isDirectory: true),
- URL(fileURLWithPath: "/tmp/cmux-services/a/file.txt", isDirectory: false),
- URL(fileURLWithPath: "/tmp/cmux-services/a", isDirectory: true),
- URL(fileURLWithPath: "/tmp/cmux-services/b/file.txt", isDirectory: false),
- ]
-
- let directories = FinderServicePathResolver.orderedUniqueDirectories(from: input)
- XCTAssertEqual(
- directories,
- [
- "/tmp/cmux-services/b",
- "/tmp/cmux-services/a",
- ]
- )
- }
-}
-
-final class TerminalDirectoryOpenTargetAvailabilityTests: XCTestCase {
- private func environment(
- existingPaths: Set,
- homeDirectoryPath: String = "/Users/tester",
- applicationPathsByName: [String: String] = [:]
- ) -> TerminalDirectoryOpenTarget.DetectionEnvironment {
- TerminalDirectoryOpenTarget.DetectionEnvironment(
- homeDirectoryPath: homeDirectoryPath,
- fileExistsAtPath: { existingPaths.contains($0) },
- isExecutableFileAtPath: { existingPaths.contains($0) },
- applicationPathForName: { applicationPathsByName[$0] }
- )
- }
-
- func testAvailableTargetsDetectSystemApplications() {
- let env = environment(
- existingPaths: [
- "/Applications/Visual Studio Code.app",
- "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code-tunnel",
- "/System/Library/CoreServices/Finder.app",
- "/System/Applications/Utilities/Terminal.app",
- "/Applications/Zed Preview.app",
- ]
- )
-
- let availableTargets = TerminalDirectoryOpenTarget.availableTargets(in: env)
- XCTAssertTrue(availableTargets.contains(.vscode))
- XCTAssertTrue(availableTargets.contains(.finder))
- XCTAssertTrue(availableTargets.contains(.terminal))
- XCTAssertTrue(availableTargets.contains(.zed))
- XCTAssertFalse(availableTargets.contains(.cursor))
- }
-
- func testAvailableTargetsFallbackToUserApplications() {
- let env = environment(
- existingPaths: [
- "/Users/tester/Applications/Cursor.app",
- "/Users/tester/Applications/Warp.app",
- "/Users/tester/Applications/Android Studio.app",
- ]
- )
-
- let availableTargets = TerminalDirectoryOpenTarget.availableTargets(in: env)
- XCTAssertTrue(availableTargets.contains(.cursor))
- XCTAssertTrue(availableTargets.contains(.warp))
- XCTAssertTrue(availableTargets.contains(.androidStudio))
- XCTAssertFalse(availableTargets.contains(.vscode))
- }
-
- func testVSCodeInlineRequiresCodeTunnelExecutable() {
- let env = environment(existingPaths: ["/Applications/Visual Studio Code.app"])
- XCTAssertTrue(TerminalDirectoryOpenTarget.vscode.isAvailable(in: env))
- XCTAssertFalse(TerminalDirectoryOpenTarget.vscodeInline.isAvailable(in: env))
- }
-
- func testITerm2DetectsLegacyBundleName() {
- let env = environment(existingPaths: ["/Applications/iTerm.app"])
- XCTAssertTrue(TerminalDirectoryOpenTarget.iterm2.isAvailable(in: env))
- }
-
- func testTowerDetected() {
- let env = environment(existingPaths: ["/Applications/Tower.app"])
- XCTAssertTrue(TerminalDirectoryOpenTarget.tower.isAvailable(in: env))
- }
-
- func testAvailableTargetsFallbackToApplicationLookupForVSCodeAliasOutsideApplications() {
- let vscodePath = "/Volumes/Tools/Code.app"
- let env = environment(
- existingPaths: [
- vscodePath,
- "\(vscodePath)/Contents/Resources/app/bin/code-tunnel",
- ],
- applicationPathsByName: [
- "Code": vscodePath,
- ]
- )
-
- let availableTargets = TerminalDirectoryOpenTarget.availableTargets(in: env)
- XCTAssertTrue(availableTargets.contains(.vscode))
- XCTAssertTrue(availableTargets.contains(.vscodeInline))
- }
-
- func testTowerDetectedViaApplicationLookupOutsideApplications() {
- let towerPath = "/Volumes/Setapp/Tower.app"
- let env = environment(
- existingPaths: [towerPath],
- applicationPathsByName: [
- "Tower": towerPath,
- ]
- )
-
- XCTAssertTrue(TerminalDirectoryOpenTarget.tower.isAvailable(in: env))
- }
-
- func testCommandPaletteShortcutsExcludeGenericIDEEntry() {
- let targets = TerminalDirectoryOpenTarget.commandPaletteShortcutTargets
- XCTAssertFalse(targets.contains(where: { $0.commandPaletteTitle == "Open Current Directory in IDE" }))
- XCTAssertFalse(targets.contains(where: { $0.commandPaletteCommandId == "palette.terminalOpenDirectory" }))
- }
-}
-
-final class VSCodeServeWebURLBuilderTests: XCTestCase {
- func testExtractWebUIURLParsesServeWebOutput() {
- let output = """
- *
- * Visual Studio Code Server
- *
- Web UI available at http://127.0.0.1:5555?tkn=test-token
- """
-
- let url = VSCodeServeWebURLBuilder.extractWebUIURL(from: output)
- XCTAssertEqual(url?.absoluteString, "http://127.0.0.1:5555?tkn=test-token")
- }
-
- func testOpenFolderURLAppendsFolderQueryWhilePreservingToken() {
- let baseURL = URL(string: "http://127.0.0.1:5555?tkn=test-token")!
-
- let url = VSCodeServeWebURLBuilder.openFolderURL(
- baseWebUIURL: baseURL,
- directoryPath: "/Users/tester/Projects/cmux"
- )
-
- let components = URLComponents(url: url!, resolvingAgainstBaseURL: false)
- XCTAssertEqual(components?.queryItems?.first(where: { $0.name == "tkn" })?.value, "test-token")
- XCTAssertEqual(components?.queryItems?.first(where: { $0.name == "folder" })?.value, "/Users/tester/Projects/cmux")
- }
-
- func testOpenFolderURLReplacesExistingFolderQuery() {
- let baseURL = URL(string: "http://127.0.0.1:5555?tkn=test-token&folder=/tmp/old")!
-
- let url = VSCodeServeWebURLBuilder.openFolderURL(
- baseWebUIURL: baseURL,
- directoryPath: "/Users/tester/New Folder"
- )
-
- let components = URLComponents(url: url!, resolvingAgainstBaseURL: false)
- XCTAssertEqual(
- components?.queryItems?.filter { $0.name == "folder" }.count,
- 1
- )
- XCTAssertEqual(
- components?.queryItems?.first(where: { $0.name == "folder" })?.value,
- "/Users/tester/New Folder"
- )
- }
-}
-
-final class VSCodeCLILaunchConfigurationBuilderTests: XCTestCase {
- func testLaunchConfigurationUsesCodeTunnelBinary() {
- let appURL = URL(fileURLWithPath: "/Applications/Visual Studio Code.app", isDirectory: true)
- let expectedExecutablePath = "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code-tunnel"
-
- let configuration = VSCodeCLILaunchConfigurationBuilder.launchConfiguration(
- vscodeApplicationURL: appURL,
- baseEnvironment: [:],
- isExecutableAtPath: { $0 == expectedExecutablePath }
- )
-
- XCTAssertEqual(configuration?.executableURL.path, expectedExecutablePath)
- XCTAssertEqual(configuration?.argumentsPrefix, [])
- XCTAssertEqual(configuration?.environment["ELECTRON_RUN_AS_NODE"], "1")
- }
-
- func testLaunchConfigurationMapsNodeEnvironmentVariables() {
- let configuration = VSCodeCLILaunchConfigurationBuilder.launchConfiguration(
- vscodeApplicationURL: URL(fileURLWithPath: "/Applications/Visual Studio Code.app", isDirectory: true),
- baseEnvironment: [
- "PATH": "/usr/bin:/bin",
- "NODE_OPTIONS": "--max-old-space-size=4096",
- "NODE_REPL_EXTERNAL_MODULE": "module-name"
- ],
- isExecutableAtPath: { _ in true }
- )
-
- XCTAssertEqual(configuration?.environment["PATH"], "/usr/bin:/bin")
- XCTAssertEqual(configuration?.environment["VSCODE_NODE_OPTIONS"], "--max-old-space-size=4096")
- XCTAssertEqual(configuration?.environment["VSCODE_NODE_REPL_EXTERNAL_MODULE"], "module-name")
- XCTAssertNil(configuration?.environment["NODE_OPTIONS"])
- XCTAssertNil(configuration?.environment["NODE_REPL_EXTERNAL_MODULE"])
- }
-
- func testLaunchConfigurationClearsStaleVSCodeNodeVariablesWhenNodeVariablesAreAbsent() {
- let configuration = VSCodeCLILaunchConfigurationBuilder.launchConfiguration(
- vscodeApplicationURL: URL(fileURLWithPath: "/Applications/Visual Studio Code.app", isDirectory: true),
- baseEnvironment: [
- "PATH": "/usr/bin:/bin",
- "VSCODE_NODE_OPTIONS": "--stale",
- "VSCODE_NODE_REPL_EXTERNAL_MODULE": "stale-module"
- ],
- isExecutableAtPath: { _ in true }
- )
-
- XCTAssertEqual(configuration?.environment["PATH"], "/usr/bin:/bin")
- XCTAssertNil(configuration?.environment["VSCODE_NODE_OPTIONS"])
- XCTAssertNil(configuration?.environment["VSCODE_NODE_REPL_EXTERNAL_MODULE"])
- }
-}
-
-final class ServeWebOutputCollectorTests: XCTestCase {
- func testWaitForURLReturnsFalseAfterProcessExitSignal() {
- let collector = ServeWebOutputCollector()
-
- DispatchQueue.global().asyncAfter(deadline: .now() + 0.05) {
- collector.markProcessExited()
- }
-
- let start = Date()
- let resolved = collector.waitForURL(timeoutSeconds: 1)
- let elapsed = Date().timeIntervalSince(start)
-
- XCTAssertFalse(resolved)
- XCTAssertLessThan(elapsed, 0.5)
- }
-
- func testWaitForURLReturnsTrueWhenURLIsCollected() {
- let collector = ServeWebOutputCollector()
- let urlLine = "Web UI available at http://127.0.0.1:7777?tkn=test-token\n"
-
- DispatchQueue.global().asyncAfter(deadline: .now() + 0.05) {
- collector.append(Data(urlLine.utf8))
- }
-
- XCTAssertTrue(collector.waitForURL(timeoutSeconds: 1))
- XCTAssertEqual(collector.webUIURL?.absoluteString, "http://127.0.0.1:7777?tkn=test-token")
- }
-
- func testMarkProcessExitedParsesFinalURLWithoutTrailingNewline() {
- let collector = ServeWebOutputCollector()
- let finalChunk = "Web UI available at http://127.0.0.1:9001?tkn=final-token"
-
- collector.append(Data(finalChunk.utf8))
- collector.markProcessExited()
-
- XCTAssertTrue(collector.waitForURL(timeoutSeconds: 0.1))
- XCTAssertEqual(collector.webUIURL?.absoluteString, "http://127.0.0.1:9001?tkn=final-token")
- }
-}
-
-final class VSCodeServeWebControllerTests: XCTestCase {
- func testStopDuringInFlightLaunchDoesNotDropNextGenerationCompletion() {
- let firstLaunchStarted = expectation(description: "first launch started")
- let firstCompletionCalled = expectation(description: "first generation completion called")
- let secondCompletionCalled = expectation(description: "second generation completion called")
-
- let launchGate = DispatchSemaphore(value: 0)
- let launchCallLock = NSLock()
- var launchCallCount = 0
-
- let controller = VSCodeServeWebController.makeForTesting { _, _ in
- launchCallLock.lock()
- launchCallCount += 1
- let callNumber = launchCallCount
- launchCallLock.unlock()
-
- if callNumber == 1 {
- firstLaunchStarted.fulfill()
- _ = launchGate.wait(timeout: .now() + 1)
- }
- return nil
- }
-
- let callbackLock = NSLock()
- var firstGenerationCallbacks: [URL?] = []
- var secondGenerationCallbacks: [URL?] = []
- let vscodeAppURL = URL(fileURLWithPath: "/Applications/Visual Studio Code.app", isDirectory: true)
-
- controller.ensureServeWebURL(vscodeApplicationURL: vscodeAppURL) { url in
- callbackLock.lock()
- firstGenerationCallbacks.append(url)
- callbackLock.unlock()
- firstCompletionCalled.fulfill()
- }
-
- wait(for: [firstLaunchStarted], timeout: 1)
- controller.stop()
-
- controller.ensureServeWebURL(vscodeApplicationURL: vscodeAppURL) { url in
- callbackLock.lock()
- secondGenerationCallbacks.append(url)
- callbackLock.unlock()
- secondCompletionCalled.fulfill()
- }
-
- launchGate.signal()
- wait(for: [firstCompletionCalled, secondCompletionCalled], timeout: 2)
-
- callbackLock.lock()
- let firstSnapshot = firstGenerationCallbacks
- let secondSnapshot = secondGenerationCallbacks
- callbackLock.unlock()
-
- launchCallLock.lock()
- let launchCalls = launchCallCount
- launchCallLock.unlock()
-
- XCTAssertEqual(firstSnapshot.count, 1)
- if firstSnapshot.count == 1 {
- XCTAssertNil(firstSnapshot[0])
- }
- XCTAssertEqual(secondSnapshot.count, 1)
- if secondSnapshot.count == 1 {
- XCTAssertNil(secondSnapshot[0])
- }
- XCTAssertEqual(launchCalls, 2)
- }
-
- func testStopRemovesOrphanedConnectionTokenFiles() throws {
- let tokenFileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
- defer { try? FileManager.default.removeItem(at: tokenFileURL) }
- try Data("token".utf8).write(to: tokenFileURL)
- XCTAssertTrue(FileManager.default.fileExists(atPath: tokenFileURL.path))
-
- let controller = VSCodeServeWebController.makeForTesting { _, _ in
- XCTFail("Expected no launch")
- return nil
- }
- controller.trackConnectionTokenFileForTesting(tokenFileURL)
-
- controller.stop()
-
- XCTAssertFalse(FileManager.default.fileExists(atPath: tokenFileURL.path))
- }
-}
-
-final class BrowserSearchEngineTests: XCTestCase {
- func testGoogleSearchURL() throws {
- let url = try XCTUnwrap(BrowserSearchEngine.google.searchURL(query: "hello world"))
- XCTAssertEqual(url.host, "www.google.com")
- XCTAssertEqual(url.path, "/search")
- XCTAssertTrue(url.absoluteString.contains("q=hello%20world"))
- }
-
- func testDuckDuckGoSearchURL() throws {
- let url = try XCTUnwrap(BrowserSearchEngine.duckduckgo.searchURL(query: "hello world"))
- XCTAssertEqual(url.host, "duckduckgo.com")
- XCTAssertEqual(url.path, "/")
- XCTAssertTrue(url.absoluteString.contains("q=hello%20world"))
- }
-
- func testBingSearchURL() throws {
- let url = try XCTUnwrap(BrowserSearchEngine.bing.searchURL(query: "hello world"))
- XCTAssertEqual(url.host, "www.bing.com")
- XCTAssertEqual(url.path, "/search")
- XCTAssertTrue(url.absoluteString.contains("q=hello%20world"))
- }
-}
-
-final class BrowserSearchSettingsTests: XCTestCase {
- func testCurrentSearchSuggestionsEnabledDefaultsToTrueWhenUnset() {
- let suiteName = "BrowserSearchSettingsTests.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer {
- defaults.removePersistentDomain(forName: suiteName)
- }
-
- defaults.removeObject(forKey: BrowserSearchSettings.searchSuggestionsEnabledKey)
- XCTAssertTrue(BrowserSearchSettings.currentSearchSuggestionsEnabled(defaults: defaults))
- }
-
- func testCurrentSearchSuggestionsEnabledHonorsExplicitValue() {
- let suiteName = "BrowserSearchSettingsTests.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer {
- defaults.removePersistentDomain(forName: suiteName)
- }
-
- defaults.set(false, forKey: BrowserSearchSettings.searchSuggestionsEnabledKey)
- XCTAssertFalse(BrowserSearchSettings.currentSearchSuggestionsEnabled(defaults: defaults))
-
- defaults.set(true, forKey: BrowserSearchSettings.searchSuggestionsEnabledKey)
- XCTAssertTrue(BrowserSearchSettings.currentSearchSuggestionsEnabled(defaults: defaults))
- }
-}
-
-final class BrowserHistoryStoreTests: XCTestCase {
- func testRecordVisitDedupesAndSuggests() async throws {
- let tempDir = FileManager.default.temporaryDirectory
- .appendingPathComponent("BrowserHistoryStoreTests-\(UUID().uuidString)", isDirectory: true)
- try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
- defer {
- try? FileManager.default.removeItem(at: tempDir)
- }
-
- let fileURL = tempDir.appendingPathComponent("browser_history.json")
- let store = await MainActor.run { BrowserHistoryStore(fileURL: fileURL) }
-
- let u1 = try XCTUnwrap(URL(string: "https://example.com/foo"))
- let u2 = try XCTUnwrap(URL(string: "https://example.com/bar"))
-
- await MainActor.run {
- store.recordVisit(url: u1, title: "Example Foo")
- store.recordVisit(url: u2, title: "Example Bar")
- store.recordVisit(url: u1, title: "Example Foo Updated")
- }
-
- let suggestions = await MainActor.run { store.suggestions(for: "foo", limit: 10) }
- XCTAssertEqual(suggestions.first?.url, "https://example.com/foo")
- XCTAssertEqual(suggestions.first?.visitCount, 2)
- XCTAssertEqual(suggestions.first?.title, "Example Foo Updated")
- }
-
- func testSuggestionsLoadsPersistedHistoryImmediatelyOnFirstQuery() async throws {
- let tempDir = FileManager.default.temporaryDirectory
- .appendingPathComponent("BrowserHistoryStoreTests-\(UUID().uuidString)", isDirectory: true)
- try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
- defer {
- try? FileManager.default.removeItem(at: tempDir)
- }
-
- let fileURL = tempDir.appendingPathComponent("browser_history.json")
- let now = Date()
- let seededEntries = [
- BrowserHistoryStore.Entry(
- id: UUID(),
- url: "https://go.dev/",
- title: "The Go Programming Language",
- lastVisited: now,
- visitCount: 3
- ),
- BrowserHistoryStore.Entry(
- id: UUID(),
- url: "https://www.google.com/",
- title: "Google",
- lastVisited: now.addingTimeInterval(-120),
- visitCount: 2
- ),
- ]
-
- let encoder = JSONEncoder()
- encoder.outputFormatting = [.withoutEscapingSlashes]
- let data = try encoder.encode(seededEntries)
- try data.write(to: fileURL, options: [.atomic])
-
- let store = await MainActor.run { BrowserHistoryStore(fileURL: fileURL) }
- let suggestions = await MainActor.run { store.suggestions(for: "go", limit: 10) }
-
- XCTAssertGreaterThanOrEqual(suggestions.count, 2)
- XCTAssertEqual(suggestions.first?.url, "https://go.dev/")
- XCTAssertTrue(suggestions.contains(where: { $0.url == "https://www.google.com/" }))
- }
-}
-
-final class OmnibarStateMachineTests: XCTestCase {
- func testEscapeRevertsWhenEditingThenBlursOnSecondEscape() throws {
- var state = OmnibarState()
-
- var effects = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://example.com/"))
- XCTAssertTrue(state.isFocused)
- XCTAssertEqual(state.buffer, "https://example.com/")
- XCTAssertFalse(state.isUserEditing)
- XCTAssertTrue(effects.shouldSelectAll)
-
- effects = omnibarReduce(state: &state, event: .bufferChanged("exam"))
- XCTAssertTrue(state.isUserEditing)
- XCTAssertEqual(state.buffer, "exam")
- XCTAssertTrue(effects.shouldRefreshSuggestions)
-
- // Simulate an open popup.
- effects = omnibarReduce(
- state: &state,
- event: .suggestionsUpdated([.search(engineName: "Google", query: "exam")])
- )
- XCTAssertEqual(state.suggestions.count, 1)
- XCTAssertFalse(effects.shouldSelectAll)
-
- // First escape: revert + close popup + select-all.
- effects = omnibarReduce(state: &state, event: .escape)
- XCTAssertEqual(state.buffer, "https://example.com/")
- XCTAssertFalse(state.isUserEditing)
- XCTAssertTrue(state.suggestions.isEmpty)
- XCTAssertTrue(effects.shouldSelectAll)
- XCTAssertFalse(effects.shouldBlurToWebView)
-
- // Second escape: blur (since we're not editing and popup is closed).
- effects = omnibarReduce(state: &state, event: .escape)
- XCTAssertTrue(effects.shouldBlurToWebView)
- }
-
- func testPanelURLChangeDoesNotClobberUserBufferWhileEditing() throws {
- var state = OmnibarState()
- _ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://a.test/"))
- _ = omnibarReduce(state: &state, event: .bufferChanged("hello"))
- XCTAssertTrue(state.isUserEditing)
-
- _ = omnibarReduce(state: &state, event: .panelURLChanged(currentURLString: "https://b.test/"))
- XCTAssertEqual(state.currentURLString, "https://b.test/")
- XCTAssertEqual(state.buffer, "hello")
- XCTAssertTrue(state.isUserEditing)
-
- let effects = omnibarReduce(state: &state, event: .escape)
- XCTAssertEqual(state.buffer, "https://b.test/")
- XCTAssertTrue(effects.shouldSelectAll)
- }
-
- func testFocusLostRevertsUnlessSuppressed() throws {
- var state = OmnibarState()
- _ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://example.com/"))
- _ = omnibarReduce(state: &state, event: .bufferChanged("typed"))
- XCTAssertEqual(state.buffer, "typed")
-
- _ = omnibarReduce(state: &state, event: .focusLostPreserveBuffer(currentURLString: "https://example.com/"))
- XCTAssertEqual(state.buffer, "typed")
-
- _ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://example.com/"))
- _ = omnibarReduce(state: &state, event: .bufferChanged("typed2"))
- _ = omnibarReduce(state: &state, event: .focusLostRevertBuffer(currentURLString: "https://example.com/"))
- XCTAssertEqual(state.buffer, "https://example.com/")
- }
-
- func testSuggestionsUpdateKeepsSelectionAcrossNonEmptyListRefresh() throws {
- var state = OmnibarState()
- _ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://example.com/"))
- _ = omnibarReduce(state: &state, event: .bufferChanged("go"))
-
- let base: [OmnibarSuggestion] = [
- .search(engineName: "Google", query: "go"),
- .remoteSearchSuggestion("go tutorial"),
- .remoteSearchSuggestion("go json"),
- ]
- _ = omnibarReduce(state: &state, event: .suggestionsUpdated(base))
- XCTAssertEqual(state.selectedSuggestionIndex, 0)
-
- _ = omnibarReduce(state: &state, event: .moveSelection(delta: 2))
- XCTAssertEqual(state.selectedSuggestionIndex, 2)
-
- // Simulate remote merge update for the same query while popup remains open.
- let merged: [OmnibarSuggestion] = [
- .search(engineName: "Google", query: "go"),
- .remoteSearchSuggestion("go tutorial"),
- .remoteSearchSuggestion("go json"),
- .remoteSearchSuggestion("go fmt"),
- ]
- _ = omnibarReduce(state: &state, event: .suggestionsUpdated(merged))
- XCTAssertEqual(state.selectedSuggestionIndex, 2, "Expected selection to remain stable while list stays open")
- }
-
- func testSuggestionsReopenResetsSelectionToFirstRow() throws {
- var state = OmnibarState()
- _ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://example.com/"))
- _ = omnibarReduce(state: &state, event: .bufferChanged("go"))
-
- let rows: [OmnibarSuggestion] = [
- .search(engineName: "Google", query: "go"),
- .remoteSearchSuggestion("go tutorial"),
- ]
- _ = omnibarReduce(state: &state, event: .suggestionsUpdated(rows))
- _ = omnibarReduce(state: &state, event: .moveSelection(delta: 1))
- XCTAssertEqual(state.selectedSuggestionIndex, 1)
-
- _ = omnibarReduce(state: &state, event: .suggestionsUpdated([]))
- XCTAssertEqual(state.selectedSuggestionIndex, 0)
-
- _ = omnibarReduce(state: &state, event: .suggestionsUpdated(rows))
- XCTAssertEqual(state.selectedSuggestionIndex, 0, "Expected reopened popup to focus first row")
- }
-
- func testSuggestionsUpdatePrefersAutocompleteMatchWhenSelectionNotTracked() throws {
- var state = OmnibarState()
- _ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://example.com/"))
- _ = omnibarReduce(state: &state, event: .bufferChanged("gm"))
-
- let rows: [OmnibarSuggestion] = [
- .search(engineName: "Google", query: "gm"),
- .history(url: "https://google.com/", title: "Google"),
- .history(url: "https://gmail.com/", title: "Gmail"),
- ]
- _ = omnibarReduce(state: &state, event: .suggestionsUpdated(rows))
- XCTAssertEqual(state.selectedSuggestionIndex, 2, "Expected autocomplete candidate to become selected without explicit index state.")
- XCTAssertEqual(state.selectedSuggestionID, rows[2].id)
- XCTAssertTrue(omnibarSuggestionSupportsAutocompletion(query: "gm", suggestion: state.suggestions[state.selectedSuggestionIndex]))
- XCTAssertEqual(state.suggestions[state.selectedSuggestionIndex].completion, "https://gmail.com/")
- }
-}
-
-final class OmnibarRemoteSuggestionMergeTests: XCTestCase {
- func testMergeRemoteSuggestionsInsertsBelowSearchAndDedupes() {
- let now = Date()
- let entries: [BrowserHistoryStore.Entry] = [
- BrowserHistoryStore.Entry(
- id: UUID(),
- url: "https://go.dev/",
- title: "The Go Programming Language",
- lastVisited: now,
- visitCount: 10
- ),
- ]
-
- let merged = buildOmnibarSuggestions(
- query: "go",
- engineName: "Google",
- historyEntries: entries,
- openTabMatches: [],
- remoteQueries: ["go tutorial", "go.dev", "go json"],
- resolvedURL: nil,
- limit: 8
- )
-
- let completions = merged.compactMap { $0.completion }
- XCTAssertGreaterThanOrEqual(completions.count, 5)
- XCTAssertEqual(completions[0], "https://go.dev/")
- XCTAssertEqual(completions[1], "go")
-
- let remoteCompletions = Array(completions.dropFirst(2))
- XCTAssertEqual(Set(remoteCompletions), Set(["go tutorial", "go.dev", "go json"]))
- XCTAssertEqual(remoteCompletions.count, 3)
- }
-
- func testStaleRemoteSuggestionsKeptForNearbyEdits() {
- let stale = staleOmnibarRemoteSuggestionsForDisplay(
- query: "go t",
- previousRemoteQuery: "go",
- previousRemoteSuggestions: ["go tutorial", "go json", "golang tips"],
- limit: 8
- )
-
- XCTAssertEqual(stale, ["go tutorial", "go json", "golang tips"])
- }
-
- func testStaleRemoteSuggestionsTrimAndRespectLimit() {
- let stale = staleOmnibarRemoteSuggestionsForDisplay(
- query: "gooo",
- previousRemoteQuery: "goo",
- previousRemoteSuggestions: [" go tutorial ", "", "go json", " ", "go fmt"],
- limit: 2
- )
-
- XCTAssertEqual(stale, ["go tutorial", "go json"])
- }
-
- func testStaleRemoteSuggestionsDroppedForUnrelatedQuery() {
- let stale = staleOmnibarRemoteSuggestionsForDisplay(
- query: "python",
- previousRemoteQuery: "go",
- previousRemoteSuggestions: ["go tutorial", "go json"],
- limit: 8
- )
-
- XCTAssertTrue(stale.isEmpty)
- }
-}
-
-final class OmnibarSuggestionRankingTests: XCTestCase {
- private var fixedNow: Date {
- Date(timeIntervalSinceReferenceDate: 10_000_000)
- }
-
- func testSingleCharacterQueryPromotesAutocompletionMatchToFirstRow() {
- let entries: [BrowserHistoryStore.Entry] = [
- .init(
- id: UUID(),
- url: "https://news.ycombinator.com/",
- title: "News.YC",
- lastVisited: fixedNow,
- visitCount: 12,
- typedCount: 1,
- lastTypedAt: fixedNow
- ),
- .init(
- id: UUID(),
- url: "https://www.google.com/",
- title: "Google",
- lastVisited: fixedNow - 200,
- visitCount: 8,
- typedCount: 2,
- lastTypedAt: fixedNow - 200
- ),
- ]
-
- let results = buildOmnibarSuggestions(
- query: "n",
- engineName: "Google",
- historyEntries: entries,
- openTabMatches: [],
- remoteQueries: ["search google for n", "news"],
- resolvedURL: nil,
- limit: 8,
- now: fixedNow
- )
-
- XCTAssertEqual(results.first?.completion, "https://news.ycombinator.com/")
- XCTAssertNotEqual(results.map(\.completion).first, "n")
- XCTAssertTrue(results.first.map { omnibarSuggestionSupportsAutocompletion(query: "n", suggestion: $0) } ?? false)
- }
-
- func testGmAutocompleteCandidateIsFirstOnExactQueryMatch() {
- let entries: [BrowserHistoryStore.Entry] = [
- .init(
- id: UUID(),
- url: "https://google.com/",
- title: "Google",
- lastVisited: fixedNow,
- visitCount: 4,
- typedCount: 1,
- lastTypedAt: fixedNow
- ),
- .init(
- id: UUID(),
- url: "https://gmail.com/",
- title: "Gmail",
- lastVisited: fixedNow,
- visitCount: 10,
- typedCount: 2,
- lastTypedAt: fixedNow
- ),
- ]
-
- let results = buildOmnibarSuggestions(
- query: "gm",
- engineName: "Google",
- historyEntries: entries,
- openTabMatches: [],
- remoteQueries: ["gmail", "gmail.com", "google mail"],
- resolvedURL: nil,
- limit: 8,
- now: fixedNow
- )
-
- XCTAssertEqual(results.first?.completion, "https://gmail.com/")
- XCTAssertTrue(omnibarSuggestionSupportsAutocompletion(query: "gm", suggestion: results[0]))
-
- let inlineCompletion = omnibarInlineCompletionForDisplay(
- typedText: "gm",
- suggestions: results,
- isFocused: true,
- selectionRange: NSRange(location: 2, length: 0),
- hasMarkedText: false
- )
- XCTAssertNotNil(inlineCompletion)
- }
-
- func testAutocompletionCandidateWinsOverRemoteAndSearchRowsForTwoLetterQuery() {
- let entries: [BrowserHistoryStore.Entry] = [
- .init(
- id: UUID(),
- url: "https://google.com/",
- title: "Google",
- lastVisited: fixedNow,
- visitCount: 4,
- typedCount: 1,
- lastTypedAt: fixedNow
- ),
- .init(
- id: UUID(),
- url: "https://gmail.com/",
- title: "Gmail",
- lastVisited: fixedNow,
- visitCount: 10,
- typedCount: 2,
- lastTypedAt: fixedNow
- ),
- ]
-
- let results = buildOmnibarSuggestions(
- query: "gm",
- engineName: "Google",
- historyEntries: entries,
- openTabMatches: [
- .init(
- tabId: UUID(),
- panelId: UUID(),
- url: "https://gmail.com/",
- title: "Gmail",
- isKnownOpenTab: true
- ),
- ],
- remoteQueries: ["Search google for gm", "gmail", "gmail.com", "Google mail"],
- resolvedURL: nil,
- limit: 8,
- now: fixedNow
- )
-
- XCTAssertTrue(omnibarSuggestionSupportsAutocompletion(query: "gm", suggestion: results[0]))
- XCTAssertEqual(results.first?.completion, "https://gmail.com/")
- }
-
- func testSuggestionSelectionPrefersAutocompletionCandidateAfterSuggestionsUpdate() {
- let entries: [BrowserHistoryStore.Entry] = [
- .init(
- id: UUID(),
- url: "https://google.com/",
- title: "Google",
- lastVisited: fixedNow,
- visitCount: 4,
- typedCount: 1,
- lastTypedAt: fixedNow
- ),
- .init(
- id: UUID(),
- url: "https://gmail.com/",
- title: "Gmail",
- lastVisited: fixedNow,
- visitCount: 10,
- typedCount: 2,
- lastTypedAt: fixedNow
- ),
- ]
-
- let results = buildOmnibarSuggestions(
- query: "gm",
- engineName: "Google",
- historyEntries: entries,
- openTabMatches: [],
- remoteQueries: ["Search google for gm", "gmail", "gmail.com"],
- resolvedURL: nil,
- limit: 8,
- now: fixedNow
- )
-
- var state = OmnibarState()
- let _ = omnibarReduce(state: &state, event: .focusGained(currentURLString: ""))
- let _ = omnibarReduce(state: &state, event: .bufferChanged("gm"))
- let _ = omnibarReduce(state: &state, event: .suggestionsUpdated(results))
-
- XCTAssertEqual(state.selectedSuggestionIndex, 0)
- XCTAssertEqual(state.selectedSuggestionID, results[0].id)
- XCTAssertTrue(omnibarSuggestionSupportsAutocompletion(query: "gm", suggestion: state.suggestions[0]))
- }
-
- func testTwoCharQueryWithRemoteSuggestionsStillPromotesAutocompletionMatch() {
- let entries: [BrowserHistoryStore.Entry] = [
- .init(
- id: UUID(),
- url: "https://news.ycombinator.com/",
- title: "News.YC",
- lastVisited: fixedNow,
- visitCount: 12,
- typedCount: 1,
- lastTypedAt: fixedNow
- ),
- .init(
- id: UUID(),
- url: "https://www.google.com/",
- title: "Google",
- lastVisited: fixedNow - 200,
- visitCount: 8,
- typedCount: 2,
- lastTypedAt: fixedNow - 200
- ),
- ]
-
- let results = buildOmnibarSuggestions(
- query: "ne",
- engineName: "Google",
- historyEntries: entries,
- openTabMatches: [],
- remoteQueries: ["netflix", "new york times", "newegg"],
- resolvedURL: nil,
- limit: 8,
- now: fixedNow
- )
-
- // The autocompletable history entry (news.ycombinator.com) should be first despite remote results.
- XCTAssertEqual(results.first?.completion, "https://news.ycombinator.com/")
- XCTAssertTrue(results.first.map { omnibarSuggestionSupportsAutocompletion(query: "ne", suggestion: $0) } ?? false)
-
- // Remote suggestions should still appear in the results (two-char queries include them).
- let remoteCompletions = results.filter {
- if case .remote = $0.kind { return true }
- return false
- }.map(\.completion)
- XCTAssertFalse(remoteCompletions.isEmpty, "Expected remote suggestions to be present for two-char query")
- }
-
- func testGmQueryWithRemoteSuggestionsAndOpenTabPromotesAutocompletionMatch() {
- let entries: [BrowserHistoryStore.Entry] = [
- .init(
- id: UUID(),
- url: "https://google.com/",
- title: "Google",
- lastVisited: fixedNow,
- visitCount: 4,
- typedCount: 1,
- lastTypedAt: fixedNow
- ),
- .init(
- id: UUID(),
- url: "https://gmail.com/",
- title: "Gmail",
- lastVisited: fixedNow,
- visitCount: 10,
- typedCount: 2,
- lastTypedAt: fixedNow
- ),
- ]
-
- let results = buildOmnibarSuggestions(
- query: "gm",
- engineName: "Google",
- historyEntries: entries,
- openTabMatches: [
- .init(
- tabId: UUID(),
- panelId: UUID(),
- url: "https://google.com/maps",
- title: "Google Maps",
- isKnownOpenTab: true
- ),
- ],
- remoteQueries: ["gmail login", "gm stock price", "gmail.com"],
- resolvedURL: nil,
- limit: 8,
- now: fixedNow
- )
-
- // Gmail should be first (autocompletable + typed history).
- XCTAssertEqual(results.first?.completion, "https://gmail.com/")
- XCTAssertTrue(omnibarSuggestionSupportsAutocompletion(query: "gm", suggestion: results[0]))
-
- // Verify remote suggestions are present alongside history/tab matches.
- let remoteCompletions = results.filter {
- if case .remote = $0.kind { return true }
- return false
- }.map(\.completion)
- XCTAssertFalse(remoteCompletions.isEmpty, "Expected remote suggestions in results")
- let hasSearch = results.contains {
- if case .search = $0.kind { return true }
- return false
- }
- XCTAssertTrue(hasSearch, "Expected search row in results")
- }
-
- func testHistorySuggestionDisplaysTitleAndUrlOnSingleLine() {
- let row = OmnibarSuggestion.history(
- url: "https://www.example.com/path?q=1",
- title: "Example Domain"
- )
- XCTAssertEqual(row.listText, "Example Domain — example.com/path?q=1")
- XCTAssertFalse(row.listText.contains("\n"))
- }
-
- func testPublishedBufferTextUsesTypedPrefixWhenInlineSuffixIsSelected() {
- let inline = OmnibarInlineCompletion(
- typedText: "l",
- displayText: "localhost:3000",
- acceptedText: "https://localhost:3000/"
- )
-
- let published = omnibarPublishedBufferTextForFieldChange(
- fieldValue: inline.displayText,
- inlineCompletion: inline,
- selectionRange: inline.suffixRange,
- hasMarkedText: false
- )
-
- XCTAssertEqual(published, "l")
- }
-
- func testPublishedBufferTextKeepsUserTypedValueWhenDisplayDiffersFromInlineText() {
- let inline = OmnibarInlineCompletion(
- typedText: "l",
- displayText: "localhost:3000",
- acceptedText: "https://localhost:3000/"
- )
-
- let published = omnibarPublishedBufferTextForFieldChange(
- fieldValue: "la",
- inlineCompletion: inline,
- selectionRange: NSRange(location: 2, length: 0),
- hasMarkedText: false
- )
-
- XCTAssertEqual(published, "la")
- }
-
- func testInlineCompletionRenderIgnoresStaleTypedPrefixMismatch() {
- let staleInline = OmnibarInlineCompletion(
- typedText: "g",
- displayText: "github.com",
- acceptedText: "https://github.com/"
- )
-
- let active = omnibarInlineCompletionIfBufferMatchesTypedPrefix(
- bufferText: "l",
- inlineCompletion: staleInline
- )
-
- XCTAssertNil(active)
- }
-
- func testInlineCompletionRenderKeepsMatchingTypedPrefix() {
- let inline = OmnibarInlineCompletion(
- typedText: "l",
- displayText: "localhost:3000",
- acceptedText: "https://localhost:3000/"
- )
-
- let active = omnibarInlineCompletionIfBufferMatchesTypedPrefix(
- bufferText: "l",
- inlineCompletion: inline
- )
-
- XCTAssertEqual(active, inline)
- }
-
- func testInlineCompletionSkipsTitleMatchWhoseURLDoesNotStartWithTypedText() {
- // History entry: visited google.com/search?q=localhost:3000 with title
- // "localhost:3000 - Google Search". Typing "l" should NOT inline-complete
- // to "google.com/..." because that replaces the typed "l" with "g".
- let suggestions: [OmnibarSuggestion] = [
- .history(
- url: "https://www.google.com/search?q=localhost:3000",
- title: "localhost:3000 - Google Search"
- ),
- ]
-
- let result = omnibarInlineCompletionForDisplay(
- typedText: "l",
- suggestions: suggestions,
- isFocused: true,
- selectionRange: NSRange(location: 1, length: 0),
- hasMarkedText: false
- )
-
- XCTAssertNil(result, "Should not inline-complete when display text does not start with typed prefix")
- }
-}
-
-@MainActor
-final class NotificationDockBadgeTests: XCTestCase {
- private final class NotificationSettingsAlertSpy: NSAlert {
- private(set) var beginSheetModalCallCount = 0
- private(set) var runModalCallCount = 0
- var nextResponse: NSApplication.ModalResponse = .alertFirstButtonReturn
-
- override func beginSheetModal(
- for sheetWindow: NSWindow,
- completionHandler handler: ((NSApplication.ModalResponse) -> Void)?
- ) {
- beginSheetModalCallCount += 1
- handler?(nextResponse)
- }
-
- override func runModal() -> NSApplication.ModalResponse {
- runModalCallCount += 1
- return nextResponse
- }
- }
-
- override func tearDown() {
- TerminalNotificationStore.shared.resetNotificationSettingsPromptHooksForTesting()
- TerminalNotificationStore.shared.replaceNotificationsForTesting([])
- super.tearDown()
- }
-
- func testDockBadgeLabelEnabledAndCounted() {
- XCTAssertEqual(TerminalNotificationStore.dockBadgeLabel(unreadCount: 1, isEnabled: true), "1")
- XCTAssertEqual(TerminalNotificationStore.dockBadgeLabel(unreadCount: 42, isEnabled: true), "42")
- XCTAssertEqual(TerminalNotificationStore.dockBadgeLabel(unreadCount: 100, isEnabled: true), "99+")
- }
-
- func testDockBadgeLabelHiddenWhenDisabledOrZero() {
- XCTAssertNil(TerminalNotificationStore.dockBadgeLabel(unreadCount: 0, isEnabled: true))
- XCTAssertNil(TerminalNotificationStore.dockBadgeLabel(unreadCount: 5, isEnabled: false))
- }
-
- func testDockBadgeLabelShowsRunTagEvenWithoutUnread() {
- XCTAssertEqual(
- TerminalNotificationStore.dockBadgeLabel(unreadCount: 0, isEnabled: true, runTag: "verify-tag"),
- "verify-tag"
- )
- }
-
- func testDockBadgeLabelCombinesRunTagAndUnreadCount() {
- XCTAssertEqual(
- TerminalNotificationStore.dockBadgeLabel(unreadCount: 7, isEnabled: true, runTag: "verify"),
- "verify:7"
- )
- XCTAssertEqual(
- TerminalNotificationStore.dockBadgeLabel(unreadCount: 120, isEnabled: true, runTag: "verify"),
- "verify:99+"
- )
- }
-
- func testNotificationBadgePreferenceDefaultsToEnabled() {
- let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer {
- defaults.removePersistentDomain(forName: suiteName)
- }
-
- XCTAssertTrue(NotificationBadgeSettings.isDockBadgeEnabled(defaults: defaults))
-
- defaults.set(false, forKey: NotificationBadgeSettings.dockBadgeEnabledKey)
- XCTAssertFalse(NotificationBadgeSettings.isDockBadgeEnabled(defaults: defaults))
-
- defaults.set(true, forKey: NotificationBadgeSettings.dockBadgeEnabledKey)
- XCTAssertTrue(NotificationBadgeSettings.isDockBadgeEnabled(defaults: defaults))
- }
-
- func testNotificationPaneFlashPreferenceDefaultsToEnabled() {
- let suiteName = "NotificationPaneFlashSettingsTests.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer {
- defaults.removePersistentDomain(forName: suiteName)
- }
-
- XCTAssertTrue(NotificationPaneFlashSettings.isEnabled(defaults: defaults))
-
- defaults.set(false, forKey: NotificationPaneFlashSettings.enabledKey)
- XCTAssertFalse(NotificationPaneFlashSettings.isEnabled(defaults: defaults))
-
- defaults.set(true, forKey: NotificationPaneFlashSettings.enabledKey)
- XCTAssertTrue(NotificationPaneFlashSettings.isEnabled(defaults: defaults))
- }
-
- func testMenuBarExtraPreferenceDefaultsToVisible() {
- let suiteName = "MenuBarExtraVisibilityTests.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer {
- defaults.removePersistentDomain(forName: suiteName)
- }
-
- XCTAssertTrue(MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults))
-
- defaults.set(false, forKey: MenuBarExtraSettings.showInMenuBarKey)
- XCTAssertFalse(MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults))
-
- defaults.set(true, forKey: MenuBarExtraSettings.showInMenuBarKey)
- XCTAssertTrue(MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults))
- }
-
- func testNotificationSoundUsesSystemSoundForDefaultAndNamedSounds() {
- let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer {
- defaults.removePersistentDomain(forName: suiteName)
- }
-
- XCTAssertTrue(NotificationSoundSettings.usesSystemSound(defaults: defaults))
-
- defaults.set("Ping", forKey: NotificationSoundSettings.key)
- XCTAssertTrue(NotificationSoundSettings.usesSystemSound(defaults: defaults))
- XCTAssertNotNil(NotificationSoundSettings.sound(defaults: defaults))
- }
-
- func testNotificationSoundDisablesSystemSoundForNoneAndCustomFile() {
- let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer {
- defaults.removePersistentDomain(forName: suiteName)
- }
-
- defaults.set("none", forKey: NotificationSoundSettings.key)
- XCTAssertFalse(NotificationSoundSettings.usesSystemSound(defaults: defaults))
- XCTAssertNil(NotificationSoundSettings.sound(defaults: defaults))
-
- defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key)
- XCTAssertFalse(NotificationSoundSettings.usesSystemSound(defaults: defaults))
- XCTAssertNil(NotificationSoundSettings.sound(defaults: defaults))
- }
-
- func testNotificationCustomFileURLExpandsTildePath() {
- let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer {
- defaults.removePersistentDomain(forName: suiteName)
- }
-
- let rawPath = "~/Library/Sounds/my-custom.wav"
- defaults.set(rawPath, forKey: NotificationSoundSettings.customFilePathKey)
- let expectedPath = (rawPath as NSString).expandingTildeInPath
- XCTAssertEqual(NotificationSoundSettings.customFileURL(defaults: defaults)?.path, expectedPath)
- }
-
- func testNotificationCustomFileSelectionMustBeExplicit() {
- let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer {
- defaults.removePersistentDomain(forName: suiteName)
- }
-
- defaults.set("~/Library/Sounds/my-custom.wav", forKey: NotificationSoundSettings.customFilePathKey)
-
- defaults.set("none", forKey: NotificationSoundSettings.key)
- XCTAssertFalse(NotificationSoundSettings.isCustomFileSelected(defaults: defaults))
-
- defaults.set("Ping", forKey: NotificationSoundSettings.key)
- XCTAssertFalse(NotificationSoundSettings.isCustomFileSelected(defaults: defaults))
-
- defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key)
- XCTAssertTrue(NotificationSoundSettings.isCustomFileSelected(defaults: defaults))
- }
-
- func testNotificationCustomStagingPreservesSourceFileWithCmuxPrefix() {
- let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer {
- defaults.removePersistentDomain(forName: suiteName)
- }
-
- let fileManager = FileManager.default
- let soundsDirectory = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
- .appendingPathComponent("Library", isDirectory: true)
- .appendingPathComponent("Sounds", isDirectory: true)
- do {
- try fileManager.createDirectory(at: soundsDirectory, withIntermediateDirectories: true)
- } catch {
- XCTFail("Failed to create sounds directory: \(error)")
- return
- }
-
- let sourceURL = soundsDirectory.appendingPathComponent(
- "cmux-custom-notification-sound.source-\(UUID().uuidString).wav",
- isDirectory: false
- )
- defer {
- try? fileManager.removeItem(at: sourceURL)
- }
-
- do {
- try Data("test".utf8).write(to: sourceURL, options: .atomic)
- } catch {
- XCTFail("Failed to write source custom sound file: \(error)")
- return
- }
-
- defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key)
- defaults.set(sourceURL.path, forKey: NotificationSoundSettings.customFilePathKey)
-
- _ = NotificationSoundSettings.sound(defaults: defaults)
-
- guard let stagedName = NotificationSoundSettings.stagedCustomSoundName(defaults: defaults) else {
- XCTFail("Expected staged custom sound name")
- return
- }
- let stagedURL = soundsDirectory.appendingPathComponent(stagedName, isDirectory: false)
- defer {
- try? fileManager.removeItem(at: stagedURL)
- }
-
- XCTAssertTrue(fileManager.fileExists(atPath: sourceURL.path))
- XCTAssertTrue(fileManager.fileExists(atPath: stagedURL.path))
- XCTAssertTrue(stagedName.hasPrefix("cmux-custom-notification-sound-"))
- XCTAssertTrue(stagedName.hasSuffix(".wav"))
- }
-
- func testNotificationCustomUnsupportedExtensionsStageAsCaf() {
- XCTAssertEqual(
- NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "mp3"),
- "caf"
- )
- XCTAssertEqual(
- NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "M4A"),
- "caf"
- )
- XCTAssertEqual(
- NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "wav"),
- "wav"
- )
- XCTAssertEqual(
- NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "AIFF"),
- "aiff"
- )
-
- let sourceA = URL(fileURLWithPath: "/tmp/custom-a.mp3")
- let sourceB = URL(fileURLWithPath: "/tmp/custom-b.mp3")
- let stagedA = NotificationSoundSettings.stagedCustomSoundFileName(
- forSourceURL: sourceA,
- destinationExtension: "caf"
- )
- let stagedB = NotificationSoundSettings.stagedCustomSoundFileName(
- forSourceURL: sourceB,
- destinationExtension: "caf"
- )
- XCTAssertNotEqual(stagedA, stagedB)
- XCTAssertTrue(stagedA.hasPrefix("cmux-custom-notification-sound-"))
- XCTAssertTrue(stagedA.hasSuffix(".caf"))
- }
-
- func testNotificationCustomPreparationKeepsActiveSourceMetadataSidecar() {
- let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer {
- defaults.removePersistentDomain(forName: suiteName)
- }
-
- let fileManager = FileManager.default
- let soundsDirectory = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
- .appendingPathComponent("Library", isDirectory: true)
- .appendingPathComponent("Sounds", isDirectory: true)
- do {
- try fileManager.createDirectory(at: soundsDirectory, withIntermediateDirectories: true)
- } catch {
- XCTFail("Failed to create sounds directory: \(error)")
- return
- }
-
- let sourceURL = soundsDirectory.appendingPathComponent(
- "cmux-custom-notification-sound.metadata-\(UUID().uuidString).wav",
- isDirectory: false
- )
- do {
- try Data("test".utf8).write(to: sourceURL, options: .atomic)
- } catch {
- XCTFail("Failed to write source custom sound file: \(error)")
- return
- }
- defer {
- try? fileManager.removeItem(at: sourceURL)
- }
-
- defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key)
- defaults.set(sourceURL.path, forKey: NotificationSoundSettings.customFilePathKey)
-
- let prepareResult = NotificationSoundSettings.prepareCustomFileForNotifications(path: sourceURL.path)
- let stagedName: String
- switch prepareResult {
- case .success(let name):
- stagedName = name
- case .failure(let issue):
- XCTFail("Expected custom sound preparation success, got \(issue)")
- return
- }
-
- let stagedURL = soundsDirectory.appendingPathComponent(stagedName, isDirectory: false)
- let metadataURL = stagedURL.appendingPathExtension("source-metadata")
- defer {
- try? fileManager.removeItem(at: stagedURL)
- try? fileManager.removeItem(at: metadataURL)
- }
-
- XCTAssertTrue(fileManager.fileExists(atPath: stagedURL.path))
- XCTAssertTrue(fileManager.fileExists(atPath: metadataURL.path))
- }
-
- func testNotificationCustomSoundReturnsNilWhenPreparationFails() {
- let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer {
- defaults.removePersistentDomain(forName: suiteName)
- }
-
- let invalidSourceURL = FileManager.default.temporaryDirectory
- .appendingPathComponent("cmux-invalid-sound-\(UUID().uuidString).mp3", isDirectory: false)
- defer {
- try? FileManager.default.removeItem(at: invalidSourceURL)
- let stagedURL = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
- .appendingPathComponent("Library", isDirectory: true)
- .appendingPathComponent("Sounds", isDirectory: true)
- .appendingPathComponent("cmux-custom-notification-sound.caf", isDirectory: false)
- try? FileManager.default.removeItem(at: stagedURL)
- }
-
- do {
- try Data("not-audio".utf8).write(to: invalidSourceURL, options: .atomic)
- } catch {
- XCTFail("Failed to write invalid custom sound source: \(error)")
- return
- }
-
- defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key)
- defaults.set(invalidSourceURL.path, forKey: NotificationSoundSettings.customFilePathKey)
-
- XCTAssertNil(NotificationSoundSettings.sound(defaults: defaults))
- }
-
- func testNotificationCustomPreparationReportsMissingFile() {
- let missingPath = FileManager.default.temporaryDirectory
- .appendingPathComponent("cmux-missing-\(UUID().uuidString).wav", isDirectory: false)
- .path
-
- let result = NotificationSoundSettings.prepareCustomFileForNotifications(path: missingPath)
- switch result {
- case .success:
- XCTFail("Expected missing file failure")
- case .failure(let issue):
- guard case .missingFile = issue else {
- XCTFail("Expected missingFile issue, got \(issue)")
- return
- }
- }
- }
-
- func testNotificationAuthorizationStateMappingCoversKnownUNAuthorizationStatuses() {
- XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .notDetermined), .notDetermined)
- XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .denied), .denied)
- XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .authorized), .authorized)
- XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .provisional), .provisional)
- }
-
- func testNotificationAuthorizationStateDeliveryCapability() {
- XCTAssertFalse(NotificationAuthorizationState.unknown.allowsDelivery)
- XCTAssertFalse(NotificationAuthorizationState.notDetermined.allowsDelivery)
- XCTAssertFalse(NotificationAuthorizationState.denied.allowsDelivery)
- XCTAssertTrue(NotificationAuthorizationState.authorized.allowsDelivery)
- XCTAssertTrue(NotificationAuthorizationState.provisional.allowsDelivery)
- XCTAssertTrue(NotificationAuthorizationState.ephemeral.allowsDelivery)
- }
-
- func testNotificationAuthorizationDefersFirstPromptWhileAppIsInactive() {
- XCTAssertTrue(
- TerminalNotificationStore.shouldDeferAutomaticAuthorizationRequest(
- status: .notDetermined,
- isAppActive: false
- )
- )
- XCTAssertFalse(
- TerminalNotificationStore.shouldDeferAutomaticAuthorizationRequest(
- status: .notDetermined,
- isAppActive: true
- )
- )
- XCTAssertFalse(
- TerminalNotificationStore.shouldDeferAutomaticAuthorizationRequest(
- status: .authorized,
- isAppActive: false
- )
- )
- }
-
- func testNotificationAuthorizationRequestGatingAllowsSettingsRetry() {
- XCTAssertTrue(
- TerminalNotificationStore.shouldRequestAuthorization(
- isAutomaticRequest: false,
- hasRequestedAutomaticAuthorization: true
- )
- )
- XCTAssertTrue(
- TerminalNotificationStore.shouldRequestAuthorization(
- isAutomaticRequest: true,
- hasRequestedAutomaticAuthorization: false
- )
- )
- XCTAssertFalse(
- TerminalNotificationStore.shouldRequestAuthorization(
- isAutomaticRequest: true,
- hasRequestedAutomaticAuthorization: true
- )
- )
- }
-
- func testNotificationSettingsPromptUsesSheetAndNeverRunsModal() {
- let store = TerminalNotificationStore.shared
- let alertSpy = NotificationSettingsAlertSpy()
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 480, height: 320),
- styleMask: [.titled],
- backing: .buffered,
- defer: false
- )
-
- var openedURL: URL?
- store.configureNotificationSettingsPromptHooksForTesting(
- windowProvider: { window },
- alertFactory: { alertSpy },
- scheduler: { _, block in block() },
- urlOpener: { openedURL = $0 }
- )
-
- store.promptToEnableNotificationsForTesting()
- let drained = expectation(description: "main queue drained")
- DispatchQueue.main.async { drained.fulfill() }
- wait(for: [drained], timeout: 1.0)
-
- XCTAssertEqual(alertSpy.beginSheetModalCallCount, 1)
- XCTAssertEqual(alertSpy.runModalCallCount, 0)
- XCTAssertEqual(
- openedURL?.absoluteString,
- "x-apple.systempreferences:com.apple.preference.notifications"
- )
- }
-
- func testNotificationSettingsPromptRetriesUntilWindowExists() {
- let store = TerminalNotificationStore.shared
- let alertSpy = NotificationSettingsAlertSpy()
- alertSpy.nextResponse = .alertSecondButtonReturn
-
- var queuedRetryBlocks: [() -> Void] = []
- var promptWindow: NSWindow?
- store.configureNotificationSettingsPromptHooksForTesting(
- windowProvider: { promptWindow },
- alertFactory: { alertSpy },
- scheduler: { _, block in queuedRetryBlocks.append(block) },
- urlOpener: { _ in XCTFail("Should not open settings for Not Now response") }
- )
-
- store.promptToEnableNotificationsForTesting()
- let drained = expectation(description: "main queue drained")
- DispatchQueue.main.async { drained.fulfill() }
- wait(for: [drained], timeout: 1.0)
-
- XCTAssertEqual(alertSpy.beginSheetModalCallCount, 0)
- XCTAssertEqual(alertSpy.runModalCallCount, 0)
- XCTAssertEqual(queuedRetryBlocks.count, 1)
-
- promptWindow = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 480, height: 320),
- styleMask: [.titled],
- backing: .buffered,
- defer: false
- )
- queuedRetryBlocks.removeFirst()()
-
- XCTAssertEqual(alertSpy.beginSheetModalCallCount, 1)
- XCTAssertEqual(alertSpy.runModalCallCount, 0)
- }
-
- func testNotificationIndexesTrackUnreadCountsByTabAndSurface() {
- let tabA = UUID()
- let tabB = UUID()
- let surfaceA = UUID()
- let surfaceB = UUID()
- let notificationAUnread = TerminalNotification(
- id: UUID(),
- tabId: tabA,
- surfaceId: surfaceA,
- title: "A unread",
- subtitle: "",
- body: "",
- createdAt: Date(),
- isRead: false
- )
- let notificationARead = TerminalNotification(
- id: UUID(),
- tabId: tabA,
- surfaceId: surfaceB,
- title: "A read",
- subtitle: "",
- body: "",
- createdAt: Date(),
- isRead: true
- )
- let notificationBUnread = TerminalNotification(
- id: UUID(),
- tabId: tabB,
- surfaceId: nil,
- title: "B unread",
- subtitle: "",
- body: "",
- createdAt: Date(),
- isRead: false
- )
-
- let store = TerminalNotificationStore.shared
- store.replaceNotificationsForTesting([
- notificationAUnread,
- notificationARead,
- notificationBUnread
- ])
-
- XCTAssertEqual(store.unreadCount, 2)
- XCTAssertEqual(store.unreadCount(forTabId: tabA), 1)
- XCTAssertEqual(store.unreadCount(forTabId: tabB), 1)
- XCTAssertTrue(store.hasUnreadNotification(forTabId: tabA, surfaceId: surfaceA))
- XCTAssertFalse(store.hasUnreadNotification(forTabId: tabA, surfaceId: surfaceB))
- XCTAssertTrue(store.hasUnreadNotification(forTabId: tabB, surfaceId: nil))
- XCTAssertEqual(store.latestNotification(forTabId: tabA)?.id, notificationAUnread.id)
- XCTAssertEqual(store.latestNotification(forTabId: tabB)?.id, notificationBUnread.id)
- }
-
- func testNotificationIndexesUpdateAfterReadAndClearMutations() {
- let tab = UUID()
- let surfaceUnread = UUID()
- let surfaceRead = UUID()
- let unreadNotification = TerminalNotification(
- id: UUID(),
- tabId: tab,
- surfaceId: surfaceUnread,
- title: "Unread",
- subtitle: "",
- body: "",
- createdAt: Date(),
- isRead: false
- )
- let readNotification = TerminalNotification(
- id: UUID(),
- tabId: tab,
- surfaceId: surfaceRead,
- title: "Read",
- subtitle: "",
- body: "",
- createdAt: Date(),
- isRead: true
- )
-
- let store = TerminalNotificationStore.shared
- store.replaceNotificationsForTesting([unreadNotification, readNotification])
- XCTAssertEqual(store.unreadCount(forTabId: tab), 1)
- XCTAssertTrue(store.hasUnreadNotification(forTabId: tab, surfaceId: surfaceUnread))
-
- store.markRead(forTabId: tab, surfaceId: surfaceUnread)
- XCTAssertEqual(store.unreadCount(forTabId: tab), 0)
- XCTAssertFalse(store.hasUnreadNotification(forTabId: tab, surfaceId: surfaceUnread))
- XCTAssertEqual(store.latestNotification(forTabId: tab)?.id, unreadNotification.id)
-
- store.clearNotifications(forTabId: tab)
- XCTAssertEqual(store.unreadCount(forTabId: tab), 0)
- XCTAssertNil(store.latestNotification(forTabId: tab))
- }
-}
-
-@MainActor
-final class TerminalNotificationDirectInteractionTests: XCTestCase {
- private func makeWindow() -> NSWindow {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 480, height: 320),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- window.contentView = NSView(frame: window.contentRect(forFrameRect: window.frame))
- return window
- }
-
- private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent {
- guard let event = NSEvent.mouseEvent(
- with: type,
- location: location,
- modifierFlags: [],
- timestamp: ProcessInfo.processInfo.systemUptime,
- windowNumber: window.windowNumber,
- context: nil,
- eventNumber: 0,
- clickCount: 1,
- pressure: 1.0
- ) else {
- fatalError("Failed to create \(type) mouse event")
- }
- return event
- }
-
- private func makeKeyEvent(characters: String, keyCode: UInt16, window: NSWindow) -> NSEvent {
- guard let event = NSEvent.keyEvent(
- with: .keyDown,
- location: .zero,
- modifierFlags: [],
- timestamp: ProcessInfo.processInfo.systemUptime,
- windowNumber: window.windowNumber,
- context: nil,
- characters: characters,
- charactersIgnoringModifiers: characters,
- isARepeat: false,
- keyCode: keyCode
- ) else {
- fatalError("Failed to create key event")
- }
- return event
- }
-
- private func surfaceView(in hostedView: GhosttySurfaceScrollView) -> NSView? {
- hostedView.subviews
- .compactMap { $0 as? NSScrollView }
- .first?
- .documentView?
- .subviews
- .first
- }
-
- func testTerminalMouseDownDismissesUnreadWhenSurfaceIsAlreadyFirstResponder() {
- let appDelegate = AppDelegate.shared ?? AppDelegate()
- let manager = TabManager()
- let store = TerminalNotificationStore.shared
- let window = makeWindow()
-
- let originalTabManager = appDelegate.tabManager
- let originalNotificationStore = appDelegate.notificationStore
- let originalAppFocusOverride = AppFocusState.overrideIsFocused
-
- store.replaceNotificationsForTesting([])
- store.configureNotificationDeliveryHandlerForTesting { _, _ in }
- appDelegate.tabManager = manager
- appDelegate.notificationStore = store
-
- defer {
- store.replaceNotificationsForTesting([])
- store.resetNotificationDeliveryHandlerForTesting()
- appDelegate.tabManager = originalTabManager
- appDelegate.notificationStore = originalNotificationStore
- AppFocusState.overrideIsFocused = originalAppFocusOverride
- window.orderOut(nil)
- }
-
- guard let workspace = manager.selectedWorkspace,
- let terminalPanel = workspace.focusedTerminalPanel else {
- XCTFail("Expected an initial focused terminal panel")
- return
- }
-
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let hostedView = terminalPanel.hostedView
- hostedView.frame = contentView.bounds
- hostedView.autoresizingMask = [.width, .height]
- contentView.addSubview(hostedView)
- contentView.layoutSubtreeIfNeeded()
- hostedView.layoutSubtreeIfNeeded()
-
- guard let surfaceView = surfaceView(in: hostedView) else {
- XCTFail("Expected terminal surface view")
- return
- }
-
- GhosttySurfaceScrollView.resetFlashCounts()
- AppFocusState.overrideIsFocused = true
- XCTAssertTrue(window.makeFirstResponder(surfaceView))
-
- store.addNotification(
- tabId: workspace.id,
- surfaceId: terminalPanel.id,
- title: "Unread",
- subtitle: "",
- body: ""
- )
- XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id))
-
- AppFocusState.overrideIsFocused = true
- let pointInWindow = surfaceView.convert(NSPoint(x: 20, y: 20), to: nil)
- let event = makeMouseEvent(type: .leftMouseDown, location: pointInWindow, window: window)
- surfaceView.mouseDown(with: event)
- let drained = expectation(description: "flash drained")
- DispatchQueue.main.async { drained.fulfill() }
- wait(for: [drained], timeout: 1.0)
-
- XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id))
- XCTAssertEqual(GhosttySurfaceScrollView.flashCount(for: terminalPanel.id), 1)
- }
-
- func testTerminalKeyDownDismissesUnreadWhenSurfaceIsAlreadyFirstResponder() {
- let appDelegate = AppDelegate.shared ?? AppDelegate()
- let manager = TabManager()
- let store = TerminalNotificationStore.shared
- let window = makeWindow()
-
- let originalTabManager = appDelegate.tabManager
- let originalNotificationStore = appDelegate.notificationStore
- let originalAppFocusOverride = AppFocusState.overrideIsFocused
-
- store.replaceNotificationsForTesting([])
- store.configureNotificationDeliveryHandlerForTesting { _, _ in }
- appDelegate.tabManager = manager
- appDelegate.notificationStore = store
-
- defer {
- store.replaceNotificationsForTesting([])
- store.resetNotificationDeliveryHandlerForTesting()
- appDelegate.tabManager = originalTabManager
- appDelegate.notificationStore = originalNotificationStore
- AppFocusState.overrideIsFocused = originalAppFocusOverride
- window.orderOut(nil)
- }
-
- guard let workspace = manager.selectedWorkspace,
- let terminalPanel = workspace.focusedTerminalPanel else {
- XCTFail("Expected an initial focused terminal panel")
- return
- }
-
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let hostedView = terminalPanel.hostedView
- hostedView.frame = contentView.bounds
- hostedView.autoresizingMask = [.width, .height]
- contentView.addSubview(hostedView)
- contentView.layoutSubtreeIfNeeded()
- hostedView.layoutSubtreeIfNeeded()
-
- guard let surfaceView = surfaceView(in: hostedView) as? GhosttyNSView else {
- XCTFail("Expected terminal surface view")
- return
- }
-
- GhosttySurfaceScrollView.resetFlashCounts()
- AppFocusState.overrideIsFocused = true
- XCTAssertTrue(window.makeFirstResponder(surfaceView))
-
- store.addNotification(
- tabId: workspace.id,
- surfaceId: terminalPanel.id,
- title: "Unread",
- subtitle: "",
- body: ""
- )
- XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id))
-
- let event = makeKeyEvent(characters: "", keyCode: 122, window: window)
- surfaceView.keyDown(with: event)
- let drained = expectation(description: "flash drained")
- DispatchQueue.main.async { drained.fulfill() }
- wait(for: [drained], timeout: 1.0)
-
- XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id))
- XCTAssertEqual(GhosttySurfaceScrollView.flashCount(for: terminalPanel.id), 1)
- }
-}
-
-
-final class MenuBarBadgeLabelFormatterTests: XCTestCase {
- func testBadgeLabelFormatting() {
- XCTAssertNil(MenuBarBadgeLabelFormatter.badgeText(for: 0))
- XCTAssertEqual(MenuBarBadgeLabelFormatter.badgeText(for: 1), "1")
- XCTAssertEqual(MenuBarBadgeLabelFormatter.badgeText(for: 9), "9")
- XCTAssertEqual(MenuBarBadgeLabelFormatter.badgeText(for: 10), "9+")
- XCTAssertEqual(MenuBarBadgeLabelFormatter.badgeText(for: 47), "9+")
- }
-}
-
-final class NotificationMenuSnapshotBuilderTests: XCTestCase {
- func testSnapshotCountsUnreadAndLimitsRecentItems() {
- let notifications = (0..<8).map { index in
- TerminalNotification(
- id: UUID(),
- tabId: UUID(),
- surfaceId: nil,
- title: "N\(index)",
- subtitle: "",
- body: "",
- createdAt: Date(timeIntervalSince1970: TimeInterval(index)),
- isRead: index.isMultiple(of: 2)
- )
- }
-
- let snapshot = NotificationMenuSnapshotBuilder.make(
- notifications: notifications,
- maxInlineNotificationItems: 3
- )
-
- XCTAssertEqual(snapshot.unreadCount, 4)
- XCTAssertTrue(snapshot.hasNotifications)
- XCTAssertTrue(snapshot.hasUnreadNotifications)
- XCTAssertEqual(snapshot.recentNotifications.count, 3)
- XCTAssertEqual(snapshot.recentNotifications.map(\.id), Array(notifications.prefix(3)).map(\.id))
- }
-
- func testStateHintTitleHandlesSingularPluralAndZero() {
- XCTAssertEqual(NotificationMenuSnapshotBuilder.stateHintTitle(unreadCount: 0), "No unread notifications")
- XCTAssertEqual(NotificationMenuSnapshotBuilder.stateHintTitle(unreadCount: 1), "1 unread notification")
- XCTAssertEqual(NotificationMenuSnapshotBuilder.stateHintTitle(unreadCount: 2), "2 unread notifications")
- }
-}
-
-final class MenuBarBuildHintFormatterTests: XCTestCase {
- func testReleaseBuildShowsNoHint() {
- XCTAssertNil(MenuBarBuildHintFormatter.menuTitle(appName: "cmux DEV menubar-extra", isDebugBuild: false))
- }
-
- func testDebugBuildWithTagShowsTag() {
- XCTAssertEqual(
- MenuBarBuildHintFormatter.menuTitle(appName: "cmux DEV menubar-extra", isDebugBuild: true),
- "Build Tag: menubar-extra"
- )
- }
-
- func testDebugBuildWithoutTagShowsUntagged() {
- XCTAssertEqual(
- MenuBarBuildHintFormatter.menuTitle(appName: "cmux DEV", isDebugBuild: true),
- "Build: DEV (untagged)"
- )
- }
-}
-
-final class MenuBarNotificationLineFormatterTests: XCTestCase {
- func testPlainTitleContainsUnreadDotBodyAndTab() {
- let notification = TerminalNotification(
- id: UUID(),
- tabId: UUID(),
- surfaceId: nil,
- title: "Build finished",
- subtitle: "",
- body: "All checks passed",
- createdAt: Date(timeIntervalSince1970: 0),
- isRead: false
- )
-
- let line = MenuBarNotificationLineFormatter.plainTitle(notification: notification, tabTitle: "workspace-1")
- XCTAssertTrue(line.hasPrefix("● Build finished"))
- XCTAssertTrue(line.contains("All checks passed"))
- XCTAssertTrue(line.contains("workspace-1"))
- }
-
- func testPlainTitleFallsBackToSubtitleWhenBodyEmpty() {
- let notification = TerminalNotification(
- id: UUID(),
- tabId: UUID(),
- surfaceId: nil,
- title: "Deploy",
- subtitle: "staging",
- body: "",
- createdAt: Date(timeIntervalSince1970: 0),
- isRead: true
- )
-
- let line = MenuBarNotificationLineFormatter.plainTitle(notification: notification, tabTitle: nil)
- XCTAssertTrue(line.hasPrefix(" Deploy"))
- XCTAssertTrue(line.contains("staging"))
- }
-
- func testMenuTitleWrapsAndTruncatesToThreeLines() {
- let notification = TerminalNotification(
- id: UUID(),
- tabId: UUID(),
- surfaceId: nil,
- title: "Extremely long notification title for wrapping behavior validation",
- subtitle: "",
- body: Array(repeating: "this body should wrap and eventually truncate", count: 8).joined(separator: " "),
- createdAt: Date(timeIntervalSince1970: 0),
- isRead: false
- )
-
- let title = MenuBarNotificationLineFormatter.menuTitle(
- notification: notification,
- tabTitle: "workspace-with-a-very-long-name",
- maxWidth: 120,
- maxLines: 3
- )
-
- XCTAssertLessThanOrEqual(title.components(separatedBy: "\n").count, 3)
- XCTAssertTrue(title.hasSuffix("…"))
- }
-
- func testMenuTitlePreservesShortTextWithoutEllipsis() {
- let notification = TerminalNotification(
- id: UUID(),
- tabId: UUID(),
- surfaceId: nil,
- title: "Done",
- subtitle: "",
- body: "All checks passed",
- createdAt: Date(timeIntervalSince1970: 0),
- isRead: false
- )
-
- let title = MenuBarNotificationLineFormatter.menuTitle(
- notification: notification,
- tabTitle: "w1",
- maxWidth: 320,
- maxLines: 3
- )
-
- XCTAssertFalse(title.hasSuffix("…"))
- }
-}
-
-
-final class MenuBarIconDebugSettingsTests: XCTestCase {
- func testDisplayedUnreadCountUsesPreviewOverrideWhenEnabled() {
- let suiteName = "MenuBarIconDebugSettingsTests.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer { defaults.removePersistentDomain(forName: suiteName) }
-
- defaults.set(true, forKey: MenuBarIconDebugSettings.previewEnabledKey)
- defaults.set(7, forKey: MenuBarIconDebugSettings.previewCountKey)
-
- XCTAssertEqual(MenuBarIconDebugSettings.displayedUnreadCount(actualUnreadCount: 2, defaults: defaults), 7)
- }
-
- func testBadgeRenderConfigClampsInvalidValues() {
- let suiteName = "MenuBarIconDebugSettingsTests.Clamp.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer { defaults.removePersistentDomain(forName: suiteName) }
-
- defaults.set(-100, forKey: MenuBarIconDebugSettings.badgeRectXKey)
- defaults.set(200, forKey: MenuBarIconDebugSettings.badgeRectYKey)
- defaults.set(-100, forKey: MenuBarIconDebugSettings.singleDigitFontSizeKey)
- defaults.set(100, forKey: MenuBarIconDebugSettings.multiDigitXAdjustKey)
-
- let config = MenuBarIconDebugSettings.badgeRenderConfig(defaults: defaults)
- XCTAssertEqual(config.badgeRect.origin.x, 0, accuracy: 0.001)
- XCTAssertEqual(config.badgeRect.origin.y, 20, accuracy: 0.001)
- XCTAssertEqual(config.singleDigitFontSize, 6, accuracy: 0.001)
- XCTAssertEqual(config.multiDigitXAdjust, 4, accuracy: 0.001)
- }
-
- func testBadgeRenderConfigUsesLegacySingleDigitXAdjustWhenNewKeyMissing() {
- let suiteName = "MenuBarIconDebugSettingsTests.LegacyX.\(UUID().uuidString)"
- guard let defaults = UserDefaults(suiteName: suiteName) else {
- XCTFail("Failed to create isolated UserDefaults suite")
- return
- }
- defer { defaults.removePersistentDomain(forName: suiteName) }
-
- defaults.set(2.5, forKey: MenuBarIconDebugSettings.legacySingleDigitXAdjustKey)
-
- let config = MenuBarIconDebugSettings.badgeRenderConfig(defaults: defaults)
- XCTAssertEqual(config.singleDigitXAdjust, 2.5, accuracy: 0.001)
- }
-}
-
-@MainActor
-
-final class MenuBarIconRendererTests: XCTestCase {
- func testImageWidthDoesNotShiftWhenBadgeAppears() {
- let noBadge = MenuBarIconRenderer.makeImage(unreadCount: 0)
- let withBadge = MenuBarIconRenderer.makeImage(unreadCount: 2)
-
- XCTAssertEqual(noBadge.size.width, 18, accuracy: 0.001)
- XCTAssertEqual(withBadge.size.width, 18, accuracy: 0.001)
- }
-}
-
-final class WorkspaceMountPolicyTests: XCTestCase {
- func testDefaultPolicyMountsOnlySelectedWorkspace() {
- let a = UUID()
- let b = UUID()
- let orderedTabIds: [UUID] = [a, b]
-
- let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
- current: [a],
- selected: b,
- pinnedIds: [],
- orderedTabIds: orderedTabIds,
- isCycleHot: false,
- maxMounted: WorkspaceMountPolicy.maxMountedWorkspaces
- )
-
- XCTAssertEqual(next, [b])
- }
-
- func testSelectedWorkspaceMovesToFrontAndMountCountIsBounded() {
- let a = UUID()
- let b = UUID()
- let c = UUID()
- let orderedTabIds: [UUID] = [a, b, c]
-
- let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
- current: [a, b, c],
- selected: c,
- pinnedIds: [],
- orderedTabIds: orderedTabIds,
- isCycleHot: false,
- maxMounted: 2
- )
-
- XCTAssertEqual(next, [c, a])
- }
-
- func testMissingWorkspacesArePruned() {
- let a = UUID()
- let b = UUID()
-
- let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
- current: [b, a],
- selected: nil,
- pinnedIds: [],
- orderedTabIds: [a],
- isCycleHot: false,
- maxMounted: 2
- )
-
- XCTAssertEqual(next, [a])
- }
-
- func testSelectedWorkspaceIsInsertedWhenAbsentFromCurrentCache() {
- let a = UUID()
- let b = UUID()
- let orderedTabIds: [UUID] = [a, b]
-
- let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
- current: [a],
- selected: b,
- pinnedIds: [],
- orderedTabIds: orderedTabIds,
- isCycleHot: false,
- maxMounted: 2
- )
-
- XCTAssertEqual(next, [b, a])
- }
-
- func testMaxMountedIsClampedToAtLeastOne() {
- let a = UUID()
- let b = UUID()
- let orderedTabIds: [UUID] = [a, b]
-
- let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
- current: [a, b],
- selected: nil,
- pinnedIds: [],
- orderedTabIds: orderedTabIds,
- isCycleHot: false,
- maxMounted: 0
- )
-
- XCTAssertEqual(next, [a])
- }
-
- func testCycleHotModeKeepsOnlySelectedWhenNoPinnedHandoff() {
- let a = UUID()
- let b = UUID()
- let c = UUID()
- let d = UUID()
- let orderedTabIds: [UUID] = [a, b, c, d]
-
- let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
- current: [a],
- selected: c,
- pinnedIds: [],
- orderedTabIds: orderedTabIds,
- isCycleHot: true,
- maxMounted: WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle
- )
-
- XCTAssertEqual(next, [c])
- }
-
- func testCycleHotModeRespectsMaxMountedLimit() {
- let a = UUID()
- let b = UUID()
- let c = UUID()
- let orderedTabIds: [UUID] = [a, b, c]
-
- let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
- current: [a, b, c],
- selected: b,
- pinnedIds: [],
- orderedTabIds: orderedTabIds,
- isCycleHot: true,
- maxMounted: 2
- )
-
- XCTAssertEqual(next, [b])
- }
-
- func testPinnedIdsAreRetainedAcrossReconcile() {
- let a = UUID()
- let b = UUID()
- let c = UUID()
- let orderedTabIds: [UUID] = [a, b, c]
-
- let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
- current: [a],
- selected: c,
- pinnedIds: [a],
- orderedTabIds: orderedTabIds,
- isCycleHot: false,
- maxMounted: 2
- )
-
- XCTAssertEqual(next, [c, a])
- }
-
- func testCycleHotModeKeepsRetiringWorkspaceWhenPinned() {
- let a = UUID()
- let b = UUID()
- let orderedTabIds: [UUID] = [a, b]
-
- let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
- current: [a],
- selected: b,
- pinnedIds: [a],
- orderedTabIds: orderedTabIds,
- isCycleHot: true,
- maxMounted: WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle
- )
-
- XCTAssertEqual(next, [b, a])
- }
-}
-
-@MainActor
-final class WindowTerminalHostViewTests: XCTestCase {
- private final class CapturingView: NSView {
- override func hitTest(_ point: NSPoint) -> NSView? {
- bounds.contains(point) ? self : nil
- }
- }
-
- private final class BonsplitMockSplitDelegate: NSObject, NSSplitViewDelegate {}
-
- func testHostViewPassesThroughWhenNoTerminalSubviewIsHit() {
- let host = WindowTerminalHostView(frame: NSRect(x: 0, y: 0, width: 200, height: 120))
-
- XCTAssertNil(host.hitTest(NSPoint(x: 10, y: 10)))
- }
-
- func testHostViewReturnsSubviewWhenSubviewIsHit() {
- let host = WindowTerminalHostView(frame: NSRect(x: 0, y: 0, width: 200, height: 120))
- let child = CapturingView(frame: NSRect(x: 20, y: 15, width: 40, height: 30))
- host.addSubview(child)
-
- XCTAssertTrue(host.hitTest(NSPoint(x: 25, y: 20)) === child)
- XCTAssertNil(host.hitTest(NSPoint(x: 150, y: 100)))
- }
-
- func testHostViewPassesThroughDividerWhenAdjacentPaneIsCollapsed() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 300, height: 180),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let splitView = NSSplitView(frame: contentView.bounds)
- splitView.autoresizingMask = [.width, .height]
- splitView.isVertical = true
- splitView.dividerStyle = .thin
- let splitDelegate = BonsplitMockSplitDelegate()
- splitView.delegate = splitDelegate
- let first = NSView(frame: NSRect(x: 0, y: 0, width: 120, height: contentView.bounds.height))
- let second = NSView(frame: NSRect(x: 121, y: 0, width: 179, height: contentView.bounds.height))
- splitView.addSubview(first)
- splitView.addSubview(second)
- contentView.addSubview(splitView)
- splitView.setPosition(1, ofDividerAt: 0)
- splitView.adjustSubviews()
- contentView.layoutSubtreeIfNeeded()
-
- let host = WindowTerminalHostView(frame: contentView.bounds)
- host.autoresizingMask = [.width, .height]
- let child = CapturingView(frame: host.bounds)
- child.autoresizingMask = [.width, .height]
- host.addSubview(child)
- contentView.addSubview(host)
-
- let dividerPointInSplit = NSPoint(
- x: splitView.arrangedSubviews[0].frame.maxX + (splitView.dividerThickness * 0.5),
- y: splitView.bounds.midY
- )
- let dividerPointInWindow = splitView.convert(dividerPointInSplit, to: nil)
- let dividerPointInHost = host.convert(dividerPointInWindow, from: nil)
- XCTAssertLessThanOrEqual(splitView.arrangedSubviews[0].frame.width, 1.5)
- XCTAssertNil(
- host.hitTest(dividerPointInHost),
- "Host view must pass through divider hits even when one pane is nearly collapsed"
- )
-
- let contentPointInSplit = NSPoint(x: dividerPointInSplit.x + 40, y: splitView.bounds.midY)
- let contentPointInWindow = splitView.convert(contentPointInSplit, to: nil)
- let contentPointInHost = host.convert(contentPointInWindow, from: nil)
- XCTAssertTrue(host.hitTest(contentPointInHost) === child)
- }
-}
-
-@MainActor
-final class WindowBrowserHostViewTests: XCTestCase {
- private final class CapturingView: NSView {
- override func hitTest(_ point: NSPoint) -> NSView? {
- bounds.contains(point) ? self : nil
- }
- }
-
- private final class PrimaryPageProbeView: NSView {
- override func hitTest(_ point: NSPoint) -> NSView? {
- bounds.contains(point) ? self : nil
- }
- }
-
- private final class WKInspectorProbeView: NSView {
- override func hitTest(_ point: NSPoint) -> NSView? {
- bounds.contains(point) ? self : nil
- }
- }
-
- private final class EdgeTransparentWKInspectorProbeView: NSView {
- override func hitTest(_ point: NSPoint) -> NSView? {
- let localPoint = convert(point, from: superview)
- guard bounds.contains(localPoint) else { return nil }
- return localPoint.x <= 12 ? nil : self
- }
- }
-
- private final class TrailingEdgeTransparentWKInspectorProbeView: NSView {
- override func hitTest(_ point: NSPoint) -> NSView? {
- let localPoint = convert(point, from: superview)
- guard bounds.contains(localPoint) else { return nil }
- return localPoint.x >= bounds.maxX - 12 ? nil : self
- }
- }
-
- private final class BonsplitMockSplitDelegate: NSObject, NSSplitViewDelegate {}
-
- private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent {
- guard let event = NSEvent.mouseEvent(
- with: type,
- location: location,
- modifierFlags: [],
- timestamp: ProcessInfo.processInfo.systemUptime,
- windowNumber: window.windowNumber,
- context: nil,
- eventNumber: 0,
- clickCount: 1,
- pressure: 1.0
- ) else {
- fatalError("Failed to create \(type) mouse event")
- }
- return event
- }
-
- private func isInspectorOwnedHit(_ hit: NSView?, inspectorView: NSView, pageView: NSView) -> Bool {
- guard let hit else { return false }
- if hit === pageView || hit.isDescendant(of: pageView) {
- return false
- }
- if hit === inspectorView || hit.isDescendant(of: inspectorView) {
- return true
- }
- return inspectorView.isDescendant(of: hit) && !(pageView === hit || pageView.isDescendant(of: hit))
- }
-
- func testHostViewPassesThroughDividerWhenAdjacentPaneIsCollapsed() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 300, height: 180),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let splitView = NSSplitView(frame: contentView.bounds)
- splitView.autoresizingMask = [.width, .height]
- splitView.isVertical = true
- splitView.dividerStyle = .thin
- let splitDelegate = BonsplitMockSplitDelegate()
- splitView.delegate = splitDelegate
- let first = NSView(frame: NSRect(x: 0, y: 0, width: 120, height: contentView.bounds.height))
- let second = NSView(frame: NSRect(x: 121, y: 0, width: 179, height: contentView.bounds.height))
- splitView.addSubview(first)
- splitView.addSubview(second)
- contentView.addSubview(splitView)
- splitView.setPosition(1, ofDividerAt: 0)
- splitView.adjustSubviews()
- contentView.layoutSubtreeIfNeeded()
-
- guard let container = contentView.superview else {
- XCTFail("Expected content container")
- return
- }
-
- let hostFrame = container.convert(contentView.bounds, from: contentView)
- let host = WindowBrowserHostView(frame: hostFrame)
- host.autoresizingMask = [.width, .height]
- let child = CapturingView(frame: host.bounds)
- child.autoresizingMask = [.width, .height]
- host.addSubview(child)
- container.addSubview(host, positioned: .above, relativeTo: contentView)
-
- let dividerPointInSplit = NSPoint(
- x: splitView.arrangedSubviews[0].frame.maxX + (splitView.dividerThickness * 0.5),
- y: splitView.bounds.midY
- )
- let dividerPointInWindow = splitView.convert(dividerPointInSplit, to: nil)
- let dividerPointInHost = host.convert(dividerPointInWindow, from: nil)
- XCTAssertLessThanOrEqual(splitView.arrangedSubviews[0].frame.width, 1.5)
- XCTAssertNil(
- host.hitTest(dividerPointInHost),
- "Browser host must pass through divider hits even when one pane is nearly collapsed"
- )
-
- let contentPointInSplit = NSPoint(x: dividerPointInSplit.x + 40, y: splitView.bounds.midY)
- let contentPointInWindow = splitView.convert(contentPointInSplit, to: nil)
- let contentPointInHost = host.convert(contentPointInWindow, from: nil)
- XCTAssertTrue(host.hitTest(contentPointInHost) === child)
- }
-
- func testWindowBrowserPortalIgnoresHostedInspectorSplitResizeNotifications() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
- guard let container = contentView.superview else {
- XCTFail("Expected content container")
- return
- }
-
- let hostFrame = container.convert(contentView.bounds, from: contentView)
- let host = WindowBrowserHostView(frame: hostFrame)
- host.autoresizingMask = [.width, .height]
- container.addSubview(host, positioned: .above, relativeTo: contentView)
-
- let appSplit = NSSplitView(frame: contentView.bounds)
- appSplit.autoresizingMask = [.width, .height]
- appSplit.isVertical = true
- appSplit.addSubview(NSView(frame: NSRect(x: 0, y: 0, width: 120, height: contentView.bounds.height)))
- appSplit.addSubview(NSView(frame: NSRect(x: 121, y: 0, width: 299, height: contentView.bounds.height)))
- contentView.addSubview(appSplit)
-
- let inspectorSplit = NSSplitView(frame: host.bounds)
- inspectorSplit.autoresizingMask = [.width, .height]
- inspectorSplit.isVertical = true
- inspectorSplit.addSubview(NSView(frame: NSRect(x: 0, y: 0, width: 120, height: host.bounds.height)))
- inspectorSplit.addSubview(NSView(frame: NSRect(x: 121, y: 0, width: 299, height: host.bounds.height)))
- host.addSubview(inspectorSplit)
-
- XCTAssertTrue(
- WindowBrowserPortal.shouldTreatSplitResizeAsExternalGeometry(
- appSplit,
- window: window,
- hostView: host
- ),
- "App layout splits should still trigger browser portal geometry sync"
- )
- XCTAssertFalse(
- WindowBrowserPortal.shouldTreatSplitResizeAsExternalGeometry(
- inspectorSplit,
- window: window,
- hostView: host
- ),
- "Hosted DevTools/internal splits should not trigger browser portal geometry sync"
- )
- }
-
- func testDragHoverEventsPassThroughForTabTransferOnBrowserHoverEvents() {
- XCTAssertTrue(
- WindowBrowserHostView.shouldPassThroughToDragTargets(
- pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType],
- eventType: .cursorUpdate
- )
- )
- XCTAssertTrue(
- WindowBrowserHostView.shouldPassThroughToDragTargets(
- pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType],
- eventType: .mouseEntered
- )
- )
- }
-
- func testDragHoverEventsPassThroughForSidebarReorderWithoutMouseButtonState() {
- XCTAssertTrue(
- WindowBrowserHostView.shouldPassThroughToDragTargets(
- pasteboardTypes: [DragOverlayRoutingPolicy.sidebarTabReorderType],
- eventType: .cursorUpdate
- )
- )
- }
-
- func testDragHoverEventsDoNotPassThroughForUnrelatedPasteboardTypes() {
- XCTAssertFalse(
- WindowBrowserHostView.shouldPassThroughToDragTargets(
- pasteboardTypes: [.fileURL],
- eventType: .cursorUpdate
- )
- )
- }
-
- func testHostViewKeepsHostedInspectorDividerInteractive() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
- guard let container = contentView.superview else {
- XCTFail("Expected content container")
- return
- }
-
- // Underlying app layout split that should still be pass-through.
- let appSplit = NSSplitView(frame: contentView.bounds)
- appSplit.autoresizingMask = [.width, .height]
- appSplit.isVertical = true
- appSplit.dividerStyle = .thin
- let appSplitDelegate = BonsplitMockSplitDelegate()
- appSplit.delegate = appSplitDelegate
- let leading = NSView(frame: NSRect(x: 0, y: 0, width: 210, height: contentView.bounds.height))
- let trailing = NSView(frame: NSRect(x: 211, y: 0, width: 209, height: contentView.bounds.height))
- appSplit.addSubview(leading)
- appSplit.addSubview(trailing)
- contentView.addSubview(appSplit)
- appSplit.adjustSubviews()
-
- let hostFrame = container.convert(contentView.bounds, from: contentView)
- let host = WindowBrowserHostView(frame: hostFrame)
- host.autoresizingMask = [.width, .height]
- container.addSubview(host, positioned: .above, relativeTo: contentView)
-
- // WebKit inspector uses an internal split (page + console). Divider drags
- // here must stay in hosted content, not pass through to appSplit behind it.
- let inspectorSplit = NSSplitView(frame: host.bounds)
- inspectorSplit.autoresizingMask = [.width, .height]
- inspectorSplit.isVertical = false
- inspectorSplit.dividerStyle = .thin
- let inspectorDelegate = BonsplitMockSplitDelegate()
- inspectorSplit.delegate = inspectorDelegate
- let pageView = CapturingView(frame: NSRect(x: 0, y: 0, width: host.bounds.width, height: 160))
- let consoleView = CapturingView(frame: NSRect(x: 0, y: 161, width: host.bounds.width, height: 99))
- inspectorSplit.addSubview(pageView)
- inspectorSplit.addSubview(consoleView)
- host.addSubview(inspectorSplit)
- inspectorSplit.setPosition(160, ofDividerAt: 0)
- inspectorSplit.adjustSubviews()
- contentView.layoutSubtreeIfNeeded()
-
- let appDividerPointInSplit = NSPoint(
- x: appSplit.arrangedSubviews[0].frame.maxX + (appSplit.dividerThickness * 0.5),
- y: appSplit.bounds.midY
- )
- let appDividerPointInWindow = appSplit.convert(appDividerPointInSplit, to: nil)
- let appDividerPointInHost = host.convert(appDividerPointInWindow, from: nil)
- XCTAssertNil(
- host.hitTest(appDividerPointInHost),
- "Underlying app split divider should still pass through with a hosted inspector split present"
- )
-
- let dividerPointInInspector = NSPoint(
- x: inspectorSplit.bounds.midX,
- y: inspectorSplit.arrangedSubviews[0].frame.maxY + (inspectorSplit.dividerThickness * 0.5)
- )
- let dividerPointInWindow = inspectorSplit.convert(dividerPointInInspector, to: nil)
- let dividerPointInHost = host.convert(dividerPointInWindow, from: nil)
- let hit = host.hitTest(dividerPointInHost)
-
- XCTAssertNotNil(
- hit,
- "Inspector divider should receive hit-testing in hosted content, not pass through"
- )
- XCTAssertFalse(hit === host)
- if let hit {
- XCTAssertTrue(
- hit === inspectorSplit || hit.isDescendant(of: inspectorSplit),
- "Expected hit to remain inside inspector split subtree"
- )
- }
- }
-
- func testHostViewKeepsHostedVerticalInspectorDividerInteractiveAtSlotLeadingEdge() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
- guard let container = contentView.superview else {
- XCTFail("Expected content container")
- return
- }
-
- let hostFrame = container.convert(contentView.bounds, from: contentView)
- let host = WindowBrowserHostView(frame: hostFrame)
- host.autoresizingMask = [.width, .height]
- container.addSubview(host, positioned: .above, relativeTo: contentView)
-
- let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height))
- slot.autoresizingMask = [.minXMargin, .height]
- host.addSubview(slot)
-
- let inspectorSplit = NSSplitView(frame: slot.bounds)
- inspectorSplit.autoresizingMask = [.width, .height]
- inspectorSplit.isVertical = true
- inspectorSplit.dividerStyle = .thin
- let inspectorDelegate = BonsplitMockSplitDelegate()
- inspectorSplit.delegate = inspectorDelegate
- let pageView = CapturingView(frame: NSRect(x: 0, y: 0, width: 1, height: slot.bounds.height))
- let inspectorView = CapturingView(
- frame: NSRect(x: 2, y: 0, width: slot.bounds.width - 2, height: slot.bounds.height)
- )
- inspectorSplit.addSubview(pageView)
- inspectorSplit.addSubview(inspectorView)
- slot.addSubview(inspectorSplit)
- inspectorSplit.setPosition(1, ofDividerAt: 0)
- inspectorSplit.adjustSubviews()
- contentView.layoutSubtreeIfNeeded()
-
- let dividerPointInSplit = NSPoint(
- x: inspectorSplit.arrangedSubviews[0].frame.maxX + (inspectorSplit.dividerThickness * 0.5),
- y: inspectorSplit.bounds.midY
- )
- let dividerPointInWindow = inspectorSplit.convert(dividerPointInSplit, to: nil)
- let dividerPointInHost = host.convert(dividerPointInWindow, from: nil)
-
- XCTAssertLessThanOrEqual(inspectorSplit.arrangedSubviews[0].frame.width, 1.5)
- XCTAssertTrue(
- abs(dividerPointInHost.x - slot.frame.minX) <= SidebarResizeInteraction.hitWidthPerSide,
- "Expected collapsed hosted divider to overlap the browser slot leading-edge resizer zone"
- )
-
- let hit = host.hitTest(dividerPointInHost)
- XCTAssertNotNil(
- hit,
- "Hosted vertical inspector divider should stay interactive even when collapsed onto the slot edge"
- )
- XCTAssertFalse(hit === host)
- if let hit {
- XCTAssertTrue(
- hit === inspectorSplit || hit.isDescendant(of: inspectorSplit),
- "Expected hit to remain inside hosted inspector split subtree at the slot edge"
- )
- }
- }
-
- func testHostViewPrefersNativeHostedInspectorSiblingDividerHit() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
- guard let container = contentView.superview else {
- XCTFail("Expected content container")
- return
- }
-
- let hostFrame = container.convert(contentView.bounds, from: contentView)
- let host = WindowBrowserHostView(frame: hostFrame)
- host.autoresizingMask = [.width, .height]
- container.addSubview(host, positioned: .above, relativeTo: contentView)
-
- let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height))
- slot.autoresizingMask = [.minXMargin, .height]
- host.addSubview(slot)
-
- let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: slot.bounds.height))
- let inspectorView = WKInspectorProbeView(
- frame: NSRect(x: 92, y: 0, width: slot.bounds.width - 92, height: slot.bounds.height)
- )
- slot.addSubview(pageView)
- slot.addSubview(inspectorView)
- contentView.layoutSubtreeIfNeeded()
-
- let dividerPointInSlot = NSPoint(x: inspectorView.frame.minX + 2, y: slot.bounds.midY)
- let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil)
- let dividerPointInHost = host.convert(dividerPointInWindow, from: nil)
- let bodyPointInSlot = NSPoint(x: inspectorView.frame.minX + 18, y: slot.bounds.midY)
- let bodyPointInWindow = slot.convert(bodyPointInSlot, to: nil)
- let bodyPointInHost = host.convert(bodyPointInWindow, from: nil)
-
- let dividerHit = host.hitTest(dividerPointInHost)
- XCTAssertTrue(
- isInspectorOwnedHit(dividerHit, inspectorView: inspectorView, pageView: pageView),
- "Hosted right-docked inspector divider should stay on the native WebKit hit path when WebKit exposes a hittable inspector-side view. actual=\(String(describing: dividerHit))"
- )
- let interiorHit = host.hitTest(bodyPointInHost)
- XCTAssertTrue(
- isInspectorOwnedHit(interiorHit, inspectorView: inspectorView, pageView: pageView),
- "Only the divider edge should be claimed; interior inspector hits should still reach WebKit content. actual=\(String(describing: interiorHit))"
- )
- }
-
- func testHostViewPrefersNativeNestedHostedInspectorSiblingDividerHit() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
- guard let container = contentView.superview else {
- XCTFail("Expected content container")
- return
- }
-
- let hostFrame = container.convert(contentView.bounds, from: contentView)
- let host = WindowBrowserHostView(frame: hostFrame)
- host.autoresizingMask = [.width, .height]
- container.addSubview(host, positioned: .above, relativeTo: contentView)
-
- let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height))
- slot.autoresizingMask = [.minXMargin, .height]
- host.addSubview(slot)
-
- let wrapper = NSView(frame: slot.bounds)
- wrapper.autoresizingMask = [.width, .height]
- slot.addSubview(wrapper)
-
- let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: wrapper.bounds.height))
- let inspectorContainer = NSView(
- frame: NSRect(x: 92, y: 0, width: wrapper.bounds.width - 92, height: wrapper.bounds.height)
- )
- let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
- inspectorView.autoresizingMask = [.width, .height]
- inspectorContainer.addSubview(inspectorView)
- wrapper.addSubview(pageView)
- wrapper.addSubview(inspectorContainer)
- contentView.layoutSubtreeIfNeeded()
-
- let dividerPointInSlot = NSPoint(x: inspectorContainer.frame.minX + 2, y: slot.bounds.midY)
- let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil)
- let dividerPointInHost = host.convert(dividerPointInWindow, from: nil)
- let bodyPointInSlot = NSPoint(x: inspectorContainer.frame.minX + 18, y: slot.bounds.midY)
- let bodyPointInWindow = slot.convert(bodyPointInSlot, to: nil)
- let bodyPointInHost = host.convert(bodyPointInWindow, from: nil)
-
- let dividerHit = host.hitTest(dividerPointInHost)
- XCTAssertTrue(
- isInspectorOwnedHit(dividerHit, inspectorView: inspectorView, pageView: pageView),
- "Portal host should prefer the native nested WebKit hit target on the right-docked divider when available. actual=\(String(describing: dividerHit))"
- )
- let interiorHit = host.hitTest(bodyPointInHost)
- XCTAssertTrue(
- isInspectorOwnedHit(interiorHit, inspectorView: inspectorView, pageView: pageView),
- "Only the divider edge should be claimed; interior nested inspector hits should still reach WebKit content. actual=\(String(describing: interiorHit))"
- )
- }
-
- func testHostViewReappliesStoredHostedInspectorWidthAfterSlotLayoutReset() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
- guard let container = contentView.superview else {
- XCTFail("Expected content container")
- return
- }
-
- let hostFrame = container.convert(contentView.bounds, from: contentView)
- let host = WindowBrowserHostView(frame: hostFrame)
- host.autoresizingMask = [.width, .height]
- container.addSubview(host, positioned: .above, relativeTo: contentView)
-
- let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height))
- slot.autoresizingMask = [.minXMargin, .height]
- host.addSubview(slot)
-
- let wrapper = NSView(frame: slot.bounds)
- wrapper.autoresizingMask = [.width, .height]
- slot.addSubview(wrapper)
-
- let originalPageFrame = NSRect(x: 0, y: 0, width: 92, height: wrapper.bounds.height)
- let originalInspectorFrame = NSRect(
- x: 92,
- y: 0,
- width: wrapper.bounds.width - 92,
- height: wrapper.bounds.height
- )
- let pageView = PrimaryPageProbeView(frame: originalPageFrame)
- let inspectorContainer = NSView(frame: originalInspectorFrame)
- let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
- inspectorView.autoresizingMask = [.width, .height]
- inspectorContainer.addSubview(inspectorView)
- wrapper.addSubview(pageView)
- wrapper.addSubview(inspectorContainer)
- contentView.layoutSubtreeIfNeeded()
-
- let dividerPointInSlot = NSPoint(x: inspectorContainer.frame.minX, y: slot.bounds.midY)
- let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil)
-
- let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)
- host.mouseDown(with: down)
- let drag = makeMouseEvent(
- type: .leftMouseDragged,
- location: NSPoint(x: dividerPointInWindow.x + 48, y: dividerPointInWindow.y),
- window: window
- )
- host.mouseDragged(with: drag)
- host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
-
- let draggedPageWidth = pageView.frame.width
- let draggedInspectorMinX = inspectorContainer.frame.minX
- XCTAssertGreaterThan(draggedPageWidth, originalPageFrame.width)
- XCTAssertGreaterThan(draggedInspectorMinX, originalInspectorFrame.minX)
-
- pageView.frame = originalPageFrame
- inspectorContainer.frame = originalInspectorFrame
- slot.needsLayout = true
- slot.layoutSubtreeIfNeeded()
- host.layoutSubtreeIfNeeded()
-
- XCTAssertEqual(pageView.frame.width, draggedPageWidth, accuracy: 0.5)
- XCTAssertEqual(inspectorContainer.frame.minX, draggedInspectorMinX, accuracy: 0.5)
- }
-
- func testHostViewFallsBackToManualHostedInspectorDragWhenNativeDividerHitIsUnavailable() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
- guard let container = contentView.superview else {
- XCTFail("Expected content container")
- return
- }
-
- let hostFrame = container.convert(contentView.bounds, from: contentView)
- let host = WindowBrowserHostView(frame: hostFrame)
- host.autoresizingMask = [.width, .height]
- container.addSubview(host, positioned: .above, relativeTo: contentView)
-
- let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height))
- slot.autoresizingMask = [.minXMargin, .height]
- host.addSubview(slot)
-
- let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: slot.bounds.height))
- let inspectorView = EdgeTransparentWKInspectorProbeView(
- frame: NSRect(x: 92, y: 0, width: slot.bounds.width - 92, height: slot.bounds.height)
- )
- slot.addSubview(pageView)
- slot.addSubview(inspectorView)
- contentView.layoutSubtreeIfNeeded()
-
- let dividerPointInSlot = NSPoint(x: inspectorView.frame.minX + 2, y: slot.bounds.midY)
- let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil)
- let dividerPointInHost = host.convert(dividerPointInWindow, from: nil)
-
- let dividerHit = host.hitTest(dividerPointInHost)
- XCTAssertTrue(
- dividerHit === host,
- "Host should only take the manual fallback path when the right-docked divider edge is not natively hittable. actual=\(String(describing: dividerHit))"
- )
-
- let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)
- host.mouseDown(with: down)
- let drag = makeMouseEvent(
- type: .leftMouseDragged,
- location: NSPoint(x: dividerPointInWindow.x + 40, y: dividerPointInWindow.y),
- window: window
- )
- host.mouseDragged(with: drag)
- host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
-
- XCTAssertGreaterThan(pageView.frame.width, 92)
- XCTAssertGreaterThan(inspectorView.frame.minX, 92)
- }
-
- func testHostViewFallsBackToManualHostedInspectorDragForLeftDockedInspector() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
- guard let container = contentView.superview else {
- XCTFail("Expected content container")
- return
- }
-
- let hostFrame = container.convert(contentView.bounds, from: contentView)
- let host = WindowBrowserHostView(frame: hostFrame)
- host.autoresizingMask = [.width, .height]
- container.addSubview(host, positioned: .above, relativeTo: contentView)
-
- let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height))
- slot.autoresizingMask = [.minXMargin, .height]
- host.addSubview(slot)
-
- let inspectorView = TrailingEdgeTransparentWKInspectorProbeView(
- frame: NSRect(x: 0, y: 0, width: 92, height: slot.bounds.height)
- )
- let pageView = PrimaryPageProbeView(
- frame: NSRect(x: 92, y: 0, width: slot.bounds.width - 92, height: slot.bounds.height)
- )
- slot.addSubview(inspectorView)
- slot.addSubview(pageView)
- contentView.layoutSubtreeIfNeeded()
-
- let dividerPointInSlot = NSPoint(x: inspectorView.frame.maxX - 2, y: slot.bounds.midY)
- let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil)
- let dividerPointInHost = host.convert(dividerPointInWindow, from: nil)
-
- XCTAssertTrue(
- host.hitTest(dividerPointInHost) === host,
- "Host should take the manual fallback path for a left-docked divider when the native edge is not hittable"
- )
-
- let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)
- host.mouseDown(with: down)
- let drag = makeMouseEvent(
- type: .leftMouseDragged,
- location: NSPoint(x: dividerPointInWindow.x + 40, y: dividerPointInWindow.y),
- window: window
- )
- host.mouseDragged(with: drag)
- host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
-
- XCTAssertGreaterThan(inspectorView.frame.width, 92)
- XCTAssertGreaterThan(pageView.frame.minX, 92)
- }
-
- func testHostViewClaimsCollapsedHostedInspectorSiblingDividerAtSlotLeadingEdge() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
- guard let container = contentView.superview else {
- XCTFail("Expected content container")
- return
- }
-
- let hostFrame = container.convert(contentView.bounds, from: contentView)
- let host = WindowBrowserHostView(frame: hostFrame)
- host.autoresizingMask = [.width, .height]
- container.addSubview(host, positioned: .above, relativeTo: contentView)
-
- let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height))
- slot.autoresizingMask = [.minXMargin, .height]
- host.addSubview(slot)
-
- let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 0, height: slot.bounds.height))
- let inspectorView = WKInspectorProbeView(frame: slot.bounds)
- slot.addSubview(pageView)
- slot.addSubview(inspectorView)
- contentView.layoutSubtreeIfNeeded()
-
- let dividerPointInSlot = NSPoint(x: inspectorView.frame.minX + 2, y: slot.bounds.midY)
- let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil)
- let dividerPointInHost = host.convert(dividerPointInWindow, from: nil)
-
- XCTAssertLessThanOrEqual(dividerPointInHost.x - slot.frame.minX, SidebarResizeInteraction.hitWidthPerSide)
- let dividerHit = host.hitTest(dividerPointInHost)
- XCTAssertTrue(
- isInspectorOwnedHit(dividerHit, inspectorView: inspectorView, pageView: pageView),
- "Collapsed right-docked hosted inspector divider should stay on the native WebKit hit path while still beating the sidebar-resizer overlap zone. actual=\(String(describing: dividerHit))"
- )
- }
-}
-
-@MainActor
-final class BrowserPanelHostContainerViewTests: XCTestCase {
- private final class PrimaryPageProbeView: NSView {
- override func hitTest(_ point: NSPoint) -> NSView? {
- bounds.contains(point) ? self : nil
- }
- }
-
- private final class TrackingInspectorFrontendWebView: WKWebView {
- private(set) var evaluatedJavaScript: [String] = []
-
- @MainActor override func evaluateJavaScript(
- _ javaScriptString: String,
- completionHandler: (@MainActor @Sendable (Any?, (any Error)?) -> Void)? = nil
- ) {
- evaluatedJavaScript.append(javaScriptString)
- completionHandler?(nil, nil)
- }
- }
-
- private final class WKInspectorProbeView: NSView {
- override func hitTest(_ point: NSPoint) -> NSView? {
- bounds.contains(point) ? self : nil
- }
- }
-
- private final class EdgeTransparentWKInspectorProbeView: NSView {
- override func hitTest(_ point: NSPoint) -> NSView? {
- let localPoint = convert(point, from: superview)
- guard bounds.contains(localPoint) else { return nil }
- return localPoint.x <= 12 ? nil : self
- }
- }
-
- private final class TrailingEdgeTransparentWKInspectorProbeView: NSView {
- override func hitTest(_ point: NSPoint) -> NSView? {
- let localPoint = convert(point, from: superview)
- guard bounds.contains(localPoint) else { return nil }
- return localPoint.x >= bounds.maxX - 12 ? nil : self
- }
- }
-
- private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent {
- guard let event = NSEvent.mouseEvent(
- with: type,
- location: location,
- modifierFlags: [],
- timestamp: ProcessInfo.processInfo.systemUptime,
- windowNumber: window.windowNumber,
- context: nil,
- eventNumber: 0,
- clickCount: 1,
- pressure: 1.0
- ) else {
- fatalError("Failed to create \(type) mouse event")
- }
- return event
- }
-
- func testBrowserPanelHostPrefersNativeHostedInspectorSiblingDividerHit() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
- host.autoresizingMask = [.minXMargin, .height]
- contentView.addSubview(host)
-
- let webViewRoot = NSView(frame: host.bounds)
- webViewRoot.autoresizingMask = [.width, .height]
- host.addSubview(webViewRoot)
-
- let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height))
- let inspectorContainer = NSView(
- frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height)
- )
- let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
- inspectorView.autoresizingMask = [.width, .height]
- inspectorContainer.addSubview(inspectorView)
- webViewRoot.addSubview(pageView)
- webViewRoot.addSubview(inspectorContainer)
- contentView.layoutSubtreeIfNeeded()
-
- let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY)
- let bodyPointInHost = NSPoint(x: inspectorContainer.frame.minX + 18, y: host.bounds.midY)
- let interiorHit = host.hitTest(bodyPointInHost)
-
- XCTAssertTrue(
- host.hitTest(dividerPointInHost) === host,
- "Browser panel host should claim the right-docked divider edge for the manual resize path"
- )
- XCTAssertTrue(
- interiorHit == nil || interiorHit !== host,
- "Only the divider edge should be claimed; interior inspector hits should not be stolen by the host. actual=\(String(describing: interiorHit))"
- )
- }
-
- func testBrowserPanelHostClaimsCollapsedHostedInspectorSiblingDividerAtLeadingEdge() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
- host.autoresizingMask = [.minXMargin, .height]
- contentView.addSubview(host)
-
- let webViewRoot = NSView(frame: host.bounds)
- webViewRoot.autoresizingMask = [.width, .height]
- host.addSubview(webViewRoot)
-
- let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 0, height: webViewRoot.bounds.height))
- let inspectorContainer = NSView(frame: webViewRoot.bounds)
- let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
- inspectorView.autoresizingMask = [.width, .height]
- inspectorContainer.addSubview(inspectorView)
- webViewRoot.addSubview(pageView)
- webViewRoot.addSubview(inspectorContainer)
- contentView.layoutSubtreeIfNeeded()
-
- let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY)
- let dividerPointInWindow = host.convert(dividerPointInHost, to: nil)
-
- XCTAssertTrue(
- host.hitTest(dividerPointInHost) === host,
- "Collapsed right-docked divider should stay on the manual browser-panel resize path while beating the sidebar-resizer overlap"
- )
-
- let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)
- host.mouseDown(with: down)
- let drag = makeMouseEvent(
- type: .leftMouseDragged,
- location: NSPoint(x: dividerPointInWindow.x + 36, y: dividerPointInWindow.y),
- window: window
- )
- host.mouseDragged(with: drag)
- host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
-
- XCTAssertGreaterThan(pageView.frame.width, 0)
- XCTAssertGreaterThan(inspectorContainer.frame.minX, 0)
- }
-
- func testBrowserPanelHostClaimsHostedInspectorDividerAcrossFullHeight() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
- host.autoresizingMask = [.minXMargin, .height]
- contentView.addSubview(host)
-
- let webViewRoot = NSView(frame: host.bounds)
- webViewRoot.autoresizingMask = [.width, .height]
- host.addSubview(webViewRoot)
-
- let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 20, width: 92, height: webViewRoot.bounds.height - 40))
- let inspectorContainer = EdgeTransparentWKInspectorProbeView(
- frame: NSRect(x: 92, y: 20, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height - 40)
- )
- webViewRoot.addSubview(pageView)
- webViewRoot.addSubview(inspectorContainer)
- contentView.layoutSubtreeIfNeeded()
-
- XCTAssertTrue(
- host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: 4)) === host,
- "The custom DevTools divider should remain draggable at the top edge of the browser pane"
- )
- XCTAssertTrue(
- host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.maxY - 4)) === host,
- "The custom DevTools divider should remain draggable at the bottom edge of the browser pane"
- )
- }
-
- func testBrowserPanelHostFallsBackToManualHostedInspectorDragWhenNativeDividerHitIsUnavailable() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
- host.autoresizingMask = [.minXMargin, .height]
- contentView.addSubview(host)
-
- let webViewRoot = NSView(frame: host.bounds)
- webViewRoot.autoresizingMask = [.width, .height]
- host.addSubview(webViewRoot)
-
- let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height))
- let inspectorContainer = EdgeTransparentWKInspectorProbeView(
- frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height)
- )
- webViewRoot.addSubview(pageView)
- webViewRoot.addSubview(inspectorContainer)
- contentView.layoutSubtreeIfNeeded()
-
- let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY)
- let dividerPointInWindow = host.convert(dividerPointInHost, to: nil)
-
- XCTAssertTrue(
- host.hitTest(dividerPointInHost) === host,
- "Browser panel host should only take the manual fallback path when the divider edge is not natively hittable"
- )
-
- let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)
- host.mouseDown(with: down)
- let drag = makeMouseEvent(
- type: .leftMouseDragged,
- location: NSPoint(x: dividerPointInWindow.x + 40, y: dividerPointInWindow.y),
- window: window
- )
- host.mouseDragged(with: drag)
- host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
-
- XCTAssertGreaterThan(pageView.frame.width, 92)
- XCTAssertGreaterThan(inspectorContainer.frame.minX, 92)
- }
-
- func testBrowserPanelHostKeepsInspectorResizableAfterShrinkingToMinimumWidth() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
- host.autoresizingMask = [.minXMargin, .height]
- contentView.addSubview(host)
-
- let webViewRoot = NSView(frame: host.bounds)
- webViewRoot.autoresizingMask = [.width, .height]
- host.addSubview(webViewRoot)
-
- let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height))
- let inspectorContainer = EdgeTransparentWKInspectorProbeView(
- frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height)
- )
- webViewRoot.addSubview(pageView)
- webViewRoot.addSubview(inspectorContainer)
- contentView.layoutSubtreeIfNeeded()
-
- let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY)
- let dividerPointInWindow = host.convert(dividerPointInHost, to: nil)
-
- host.mouseDown(with: makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window))
- let drag = makeMouseEvent(
- type: .leftMouseDragged,
- location: NSPoint(x: dividerPointInWindow.x + 220, y: dividerPointInWindow.y),
- window: window
- )
- host.mouseDragged(with: drag)
- host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
-
- XCTAssertGreaterThanOrEqual(
- inspectorContainer.frame.width,
- 120,
- "Shrinking the DevTools pane should clamp to a recoverable minimum width"
- )
- XCTAssertTrue(
- host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: 4)) === host,
- "After clamping, the DevTools divider should still be draggable near the top edge"
- )
- XCTAssertTrue(
- host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.maxY - 4)) === host,
- "After clamping, the DevTools divider should still be draggable near the bottom edge"
- )
- }
-
- func testBrowserPanelHostPromotesVisibleRightDockedInspectorIntoManagedSideDock() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
- host.autoresizingMask = [.minXMargin, .height]
- contentView.addSubview(host)
-
- let slotView = host.ensureLocalInlineSlotView()
- let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 92, height: host.bounds.height + 180))
- let inspectorView = WKWebView(
- frame: NSRect(x: 92, y: 0, width: slotView.bounds.width - 92, height: host.bounds.height)
- )
- slotView.addSubview(pageView)
- slotView.addSubview(inspectorView)
- host.pinHostedWebView(pageView, in: slotView)
- host.setHostedInspectorFrontendWebView(inspectorView)
- contentView.layoutSubtreeIfNeeded()
- host.layoutSubtreeIfNeeded()
-
- XCTAssertTrue(
- host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded(),
- "A visible right-docked inspector should not wait on async dock-configuration JS before entering the managed side-dock path"
- )
- XCTAssertTrue(
- pageView.superview === inspectorView.superview && pageView.superview !== slotView,
- "Promotion should move both hosted inspector siblings into the managed side-dock container"
- )
- XCTAssertEqual(
- pageView.frame.height,
- host.bounds.height,
- accuracy: 0.5,
- "Promotion should normalize stale page heights to the host height so the page layer stops covering the divider"
- )
- XCTAssertEqual(
- inspectorView.frame.height,
- host.bounds.height,
- accuracy: 0.5,
- "Promotion should normalize the inspector height to the host height"
- )
- }
-
- func testBrowserPanelHostAllowsRightDockedInspectorToExpandLeftAfterPromotion() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
- host.autoresizingMask = [.minXMargin, .height]
- contentView.addSubview(host)
-
- let slotView = host.ensureLocalInlineSlotView()
- let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 92, height: host.bounds.height))
- let inspectorView = WKWebView(
- frame: NSRect(x: 92, y: 0, width: slotView.bounds.width - 92, height: host.bounds.height)
- )
- slotView.addSubview(pageView)
- slotView.addSubview(inspectorView)
- host.pinHostedWebView(pageView, in: slotView)
- host.setHostedInspectorFrontendWebView(inspectorView)
- contentView.layoutSubtreeIfNeeded()
- host.layoutSubtreeIfNeeded()
-
- XCTAssertTrue(
- host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded(),
- "The managed side-dock path should be active before drag assertions run"
- )
-
- let initialPageWidth = pageView.frame.width
- let initialInspectorWidth = inspectorView.frame.width
- let dividerPointInHost = NSPoint(x: inspectorView.frame.minX + 2, y: host.bounds.midY)
- let dividerPointInWindow = host.convert(dividerPointInHost, to: nil)
-
- host.mouseDown(with: makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window))
- let drag = makeMouseEvent(
- type: .leftMouseDragged,
- location: NSPoint(x: dividerPointInWindow.x - 40, y: dividerPointInWindow.y),
- window: window
- )
- host.mouseDragged(with: drag)
- host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
-
- XCTAssertGreaterThan(
- inspectorView.frame.width,
- initialInspectorWidth,
- "Right-docked DevTools should expand when the divider is dragged left"
- )
- XCTAssertLessThan(
- pageView.frame.width,
- initialPageWidth,
- "Expanding right-docked DevTools should shrink the page width"
- )
- }
-
- func testBrowserPanelHostKeepsAutomaticRightDockedWidthAboveMinimumWhileShrinking() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 140, y: 0, width: 280, height: contentView.bounds.height))
- host.autoresizingMask = [.minXMargin, .height]
- contentView.addSubview(host)
-
- let slotView = host.ensureLocalInlineSlotView()
- let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 132, height: host.bounds.height))
- let inspectorView = WKWebView(
- frame: NSRect(x: 132, y: 0, width: slotView.bounds.width - 132, height: host.bounds.height)
- )
- slotView.addSubview(pageView)
- slotView.addSubview(inspectorView)
- host.pinHostedWebView(pageView, in: slotView)
- host.setHostedInspectorFrontendWebView(inspectorView)
- contentView.layoutSubtreeIfNeeded()
- host.layoutSubtreeIfNeeded()
-
- XCTAssertTrue(host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded())
-
- host.setPreferredHostedInspectorWidth(width: 80, widthFraction: nil)
- host.setFrameSize(NSSize(width: 210, height: host.frame.height))
- contentView.layoutSubtreeIfNeeded()
- host.layoutSubtreeIfNeeded()
-
- XCTAssertGreaterThanOrEqual(
- inspectorView.frame.width,
- 120,
- "Automatic pane resize should honor the same minimum hosted inspector width as manual dragging"
- )
- XCTAssertEqual(
- inspectorView.frame.height,
- host.bounds.height,
- accuracy: 0.5,
- "Automatic shrink should keep the inspector vertically normalized to the host height"
- )
- }
-
- func testBrowserPanelHostRequestsBottomDockWhenSideDockLeavesTooLittlePageWidth() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 280, height: contentView.bounds.height))
- host.autoresizingMask = [.minXMargin, .height]
- contentView.addSubview(host)
-
- let slotView = host.ensureLocalInlineSlotView()
- let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 120, height: host.bounds.height))
- let inspectorView = TrackingInspectorFrontendWebView(
- frame: NSRect(x: 120, y: 0, width: slotView.bounds.width - 120, height: host.bounds.height)
- )
- slotView.addSubview(pageView)
- slotView.addSubview(inspectorView)
- host.pinHostedWebView(pageView, in: slotView)
- host.setHostedInspectorFrontendWebView(inspectorView)
- contentView.layoutSubtreeIfNeeded()
- host.layoutSubtreeIfNeeded()
-
- XCTAssertTrue(host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded())
-
- host.setFrameSize(NSSize(width: 210, height: host.frame.height))
- contentView.layoutSubtreeIfNeeded()
- host.layoutSubtreeIfNeeded()
-
- XCTAssertTrue(
- inspectorView.evaluatedJavaScript.contains(where: { $0.contains("WI._dockBottom()") }),
- "Narrow pane widths should request bottom-docked DevTools instead of leaving the side-docked inspector in an unstable layout"
- )
- XCTAssertTrue(
- inspectorView.evaluatedJavaScript.contains(where: { $0.contains("const allowSideDock = false;") }),
- "Once a narrow pane proves it cannot safely side-dock DevTools, the inspector frontend should hide and disable left/right dock controls"
- )
- }
-
- func testBrowserPanelManagedSideDockDoesNotAutoresizeDraggedFrames() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
- host.autoresizingMask = [.minXMargin, .height]
- contentView.addSubview(host)
-
- let slotView = host.ensureLocalInlineSlotView()
- let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 92, height: host.bounds.height))
- let inspectorView = WKWebView(
- frame: NSRect(x: 92, y: 0, width: slotView.bounds.width - 92, height: host.bounds.height)
- )
- slotView.addSubview(pageView)
- slotView.addSubview(inspectorView)
- host.pinHostedWebView(pageView, in: slotView)
- host.setHostedInspectorFrontendWebView(inspectorView)
- contentView.layoutSubtreeIfNeeded()
- host.layoutSubtreeIfNeeded()
-
- XCTAssertTrue(host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded())
-
- let dividerPointInHost = NSPoint(x: inspectorView.frame.minX + 2, y: host.bounds.midY)
- let dividerPointInWindow = host.convert(dividerPointInHost, to: nil)
- host.mouseDown(with: makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window))
- let drag = makeMouseEvent(
- type: .leftMouseDragged,
- location: NSPoint(x: dividerPointInWindow.x - 30, y: dividerPointInWindow.y),
- window: window
- )
- host.mouseDragged(with: drag)
- host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
-
- guard let managedContainer = pageView.superview else {
- XCTFail("Expected managed side-dock container")
- return
- }
- let draggedPageFrame = pageView.frame
- let draggedInspectorFrame = inspectorView.frame
-
- managedContainer.setFrameSize(
- NSSize(width: managedContainer.frame.width, height: managedContainer.frame.height + 24)
- )
-
- XCTAssertEqual(
- pageView.frame.origin.x,
- draggedPageFrame.origin.x,
- accuracy: 0.5,
- "Managed side-dock container should not autoresize the page back to a stale divider position"
- )
- XCTAssertEqual(
- pageView.frame.width,
- draggedPageFrame.width,
- accuracy: 0.5,
- "Managed side-dock container should preserve the dragged page width until the host explicitly reapplies layout"
- )
- XCTAssertEqual(
- inspectorView.frame.origin.x,
- draggedInspectorFrame.origin.x,
- accuracy: 0.5,
- "Managed side-dock container should preserve the dragged inspector origin"
- )
- XCTAssertEqual(
- inspectorView.frame.width,
- draggedInspectorFrame.width,
- accuracy: 0.5,
- "Managed side-dock container should preserve the dragged inspector width"
- )
- }
-
- func testBrowserPanelHostFallsBackToManualHostedInspectorDragForLeftDockedInspector() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
- host.autoresizingMask = [.minXMargin, .height]
- contentView.addSubview(host)
-
- let webViewRoot = NSView(frame: host.bounds)
- webViewRoot.autoresizingMask = [.width, .height]
- host.addSubview(webViewRoot)
-
- let inspectorContainer = TrailingEdgeTransparentWKInspectorProbeView(
- frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height)
- )
- let pageView = PrimaryPageProbeView(
- frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height)
- )
- webViewRoot.addSubview(inspectorContainer)
- webViewRoot.addSubview(pageView)
- contentView.layoutSubtreeIfNeeded()
-
- let dividerPointInHost = NSPoint(x: inspectorContainer.frame.maxX - 2, y: host.bounds.midY)
- let dividerPointInWindow = host.convert(dividerPointInHost, to: nil)
-
- XCTAssertTrue(
- host.hitTest(dividerPointInHost) === host,
- "Browser panel host should take the manual fallback path for a left-docked divider when the native edge is not hittable"
- )
-
- let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)
- host.mouseDown(with: down)
- let drag = makeMouseEvent(
- type: .leftMouseDragged,
- location: NSPoint(x: dividerPointInWindow.x + 40, y: dividerPointInWindow.y),
- window: window
- )
- host.mouseDragged(with: drag)
- host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
-
- XCTAssertGreaterThan(inspectorContainer.frame.width, 92)
- XCTAssertGreaterThan(pageView.frame.minX, 92)
- }
-
- func testBrowserPanelHostReappliesStoredHostedInspectorWidthAfterLayoutReset() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let host = WebViewRepresentable.HostContainerView(
- frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)
- )
- host.autoresizingMask = [.minXMargin, .height]
- contentView.addSubview(host)
-
- let webViewRoot = NSView(frame: host.bounds)
- webViewRoot.autoresizingMask = [.width, .height]
- host.addSubview(webViewRoot)
-
- let originalPageFrame = NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height)
- let originalInspectorFrame = NSRect(
- x: 92,
- y: 0,
- width: webViewRoot.bounds.width - 92,
- height: webViewRoot.bounds.height
- )
- let pageView = PrimaryPageProbeView(frame: originalPageFrame)
- let inspectorContainer = NSView(frame: originalInspectorFrame)
- let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
- inspectorView.autoresizingMask = [.width, .height]
- inspectorContainer.addSubview(inspectorView)
- webViewRoot.addSubview(pageView)
- webViewRoot.addSubview(inspectorContainer)
- contentView.layoutSubtreeIfNeeded()
-
- let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY)
- let dividerPointInWindow = host.convert(dividerPointInHost, to: nil)
-
- let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)
- host.mouseDown(with: down)
- let drag = makeMouseEvent(
- type: .leftMouseDragged,
- location: NSPoint(x: dividerPointInWindow.x + 48, y: dividerPointInWindow.y),
- window: window
- )
- host.mouseDragged(with: drag)
- host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
-
- let draggedPageWidth = pageView.frame.width
- let draggedInspectorMinX = inspectorContainer.frame.minX
- XCTAssertGreaterThan(draggedPageWidth, originalPageFrame.width)
- XCTAssertGreaterThan(draggedInspectorMinX, originalInspectorFrame.minX)
-
- pageView.frame = originalPageFrame
- inspectorContainer.frame = originalInspectorFrame
- host.needsLayout = true
- host.layoutSubtreeIfNeeded()
-
- XCTAssertEqual(pageView.frame.width, draggedPageWidth, accuracy: 0.5)
- XCTAssertEqual(inspectorContainer.frame.minX, draggedInspectorMinX, accuracy: 0.5)
- }
-
- func testWindowBrowserSlotPinsHostedWebViewWithAutoresizingForAttachedInspector() {
- let slot = WindowBrowserSlotView(frame: NSRect(x: 0, y: 0, width: 240, height: 180))
- let webView = WKWebView(frame: .zero)
- slot.addSubview(webView)
-
- slot.pinHostedWebView(webView)
- slot.frame = NSRect(x: 0, y: 0, width: 300, height: 220)
- slot.layoutSubtreeIfNeeded()
-
- XCTAssertTrue(webView.translatesAutoresizingMaskIntoConstraints)
- XCTAssertEqual(webView.autoresizingMask, [.width, .height])
- XCTAssertEqual(webView.frame, slot.bounds)
- }
-
- func testWindowBrowserSlotReattachesPlainWebViewAtFullBoundsAfterHiddenHostResize() {
- let slot = WindowBrowserSlotView(frame: NSRect(x: 0, y: 0, width: 400, height: 180))
- let webView = WKWebView(frame: .zero)
- slot.addSubview(webView)
- slot.pinHostedWebView(webView)
- XCTAssertEqual(webView.frame, slot.bounds)
-
- let externalHost = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 180))
- webView.removeFromSuperview()
- externalHost.addSubview(webView)
- webView.frame = externalHost.bounds
- webView.translatesAutoresizingMaskIntoConstraints = true
- webView.autoresizingMask = [.width, .height]
-
- slot.addSubview(webView)
- slot.pinHostedWebView(webView)
-
- slot.frame = NSRect(x: 0, y: 0, width: 300, height: 180)
- slot.layoutSubtreeIfNeeded()
-
- XCTAssertEqual(
- webView.frame,
- slot.bounds,
- "Reattaching a plain web view should restore full-bounds hosting instead of preserving a stale inset frame from a hidden host"
- )
- }
-}
-
-@MainActor
-final class CmuxWebViewDragRoutingTests: XCTestCase {
- func testRejectsInternalPaneDragEvenWhenFilePromiseTypesArePresent() {
- XCTAssertTrue(
- CmuxWebView.shouldRejectInternalPaneDrag([
- DragOverlayRoutingPolicy.bonsplitTabTransferType,
- NSPasteboard.PasteboardType("com.apple.pasteboard.promised-file-url"),
- ])
- )
- }
-
- func testAllowsRegularExternalFileDrops() {
- XCTAssertFalse(CmuxWebView.shouldRejectInternalPaneDrag([.fileURL]))
- }
-}
-
-#if compiler(>=6.2)
-@available(macOS 26.0, *)
-private struct DragConfigurationOperationsSnapshot: Equatable {
- let allowCopy: Bool
- let allowMove: Bool
- let allowDelete: Bool
- let allowAlias: Bool
-}
-
-@available(macOS 26.0, *)
-private enum DragConfigurationSnapshotError: Error {
- case missingBoolField(primary: String, fallback: String?)
-}
-
-@available(macOS 26.0, *)
-private func dragConfigurationOperationsSnapshot(from operations: T) throws -> DragConfigurationOperationsSnapshot {
- let mirror = Mirror(reflecting: operations)
-
- func readBool(_ primary: String, fallback: String? = nil) throws -> Bool {
- if let value = mirror.descendant(primary) as? Bool {
- return value
- }
- if let fallback, let value = mirror.descendant(fallback) as? Bool {
- return value
- }
- throw DragConfigurationSnapshotError.missingBoolField(primary: primary, fallback: fallback)
- }
-
- return try DragConfigurationOperationsSnapshot(
- allowCopy: readBool("allowCopy", fallback: "_allowCopy"),
- allowMove: readBool("allowMove", fallback: "_allowMove"),
- allowDelete: readBool("allowDelete", fallback: "_allowDelete"),
- allowAlias: readBool("allowAlias", fallback: "_allowAlias")
- )
-}
-
-@MainActor
-final class InternalTabDragConfigurationTests: XCTestCase {
- func testDisablesExternalOperationsForInternalTabDrags() throws {
- guard #available(macOS 26.0, *) else {
- throw XCTSkip("Requires macOS 26 drag configuration APIs")
- }
-
- let configuration = InternalTabDragConfigurationProvider.value
- let withinApp = try dragConfigurationOperationsSnapshot(from: configuration.operationsWithinApp)
- let outsideApp = try dragConfigurationOperationsSnapshot(from: configuration.operationsOutsideApp)
-
- XCTAssertEqual(
- withinApp,
- DragConfigurationOperationsSnapshot(
- allowCopy: false,
- allowMove: true,
- allowDelete: false,
- allowAlias: false
- )
- )
-
- XCTAssertEqual(
- outsideApp,
- DragConfigurationOperationsSnapshot(
- allowCopy: false,
- allowMove: false,
- allowDelete: false,
- allowAlias: false
- )
- )
- }
-}
-
-@MainActor
-final class InternalTabDragBundleDeclarationTests: XCTestCase {
- private func exportedTypeIdentifiers(bundle: Bundle) -> Set {
- let declarations = (bundle.object(forInfoDictionaryKey: "UTExportedTypeDeclarations") as? [[String: Any]]) ?? []
- return Set(declarations.compactMap { $0["UTTypeIdentifier"] as? String })
- }
-
- func testAppBundleExportsInternalDragTypes() {
- let exported = exportedTypeIdentifiers(bundle: Bundle(for: AppDelegate.self))
-
- XCTAssertTrue(
- exported.contains("com.splittabbar.tabtransfer"),
- "Expected app bundle to export bonsplit tab-transfer type, got \(exported)"
- )
- XCTAssertTrue(
- exported.contains("com.cmux.sidebar-tab-reorder"),
- "Expected app bundle to export sidebar tab-reorder type, got \(exported)"
- )
- }
-}
-#endif
-
-@MainActor
-final class BrowserPaneDropRoutingTests: XCTestCase {
- func testVerticalZonesFollowAppKitCoordinates() {
- let size = CGSize(width: 240, height: 180)
-
- XCTAssertEqual(
- BrowserPaneDropRouting.zone(for: CGPoint(x: size.width * 0.5, y: size.height - 8), in: size),
- .top
- )
- XCTAssertEqual(
- BrowserPaneDropRouting.zone(for: CGPoint(x: size.width * 0.5, y: 8), in: size),
- .bottom
- )
- }
-
- func testTopChromeHeightPushesTopSplitThresholdIntoWebView() {
- let size = CGSize(width: 240, height: 180)
-
- XCTAssertEqual(
- BrowserPaneDropRouting.zone(
- for: CGPoint(x: size.width * 0.5, y: 110),
- in: size,
- topChromeHeight: 36
- ),
- .center
- )
- XCTAssertEqual(
- BrowserPaneDropRouting.zone(
- for: CGPoint(x: size.width * 0.5, y: 150),
- in: size,
- topChromeHeight: 36
- ),
- .top
- )
- }
-
- func testHitTestingCapturesOnlyForRelevantDragEvents() {
- XCTAssertTrue(
- BrowserPaneDropTargetView.shouldCaptureHitTesting(
- pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType],
- eventType: .cursorUpdate
- )
- )
- XCTAssertFalse(
- BrowserPaneDropTargetView.shouldCaptureHitTesting(
- pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType],
- eventType: .leftMouseDown
- )
- )
- XCTAssertFalse(
- BrowserPaneDropTargetView.shouldCaptureHitTesting(
- pasteboardTypes: [.fileURL],
- eventType: .cursorUpdate
- )
- )
- }
-
- func testCenterDropOnSamePaneIsNoOp() {
- let paneId = PaneID(id: UUID())
- let target = BrowserPaneDropContext(
- workspaceId: UUID(),
- panelId: UUID(),
- paneId: paneId
- )
- let transfer = BrowserPaneDragTransfer(
- tabId: UUID(),
- sourcePaneId: paneId.id,
- sourceProcessId: Int32(ProcessInfo.processInfo.processIdentifier)
- )
-
- XCTAssertEqual(
- BrowserPaneDropRouting.action(for: transfer, target: target, zone: .center),
- .noOp
- )
- }
-
- func testRightEdgeDropBuildsSplitMoveAction() {
- let paneId = PaneID(id: UUID())
- let target = BrowserPaneDropContext(
- workspaceId: UUID(),
- panelId: UUID(),
- paneId: paneId
- )
- let tabId = UUID()
- let transfer = BrowserPaneDragTransfer(
- tabId: tabId,
- sourcePaneId: UUID(),
- sourceProcessId: Int32(ProcessInfo.processInfo.processIdentifier)
- )
-
- XCTAssertEqual(
- BrowserPaneDropRouting.action(for: transfer, target: target, zone: .right),
- .move(
- tabId: tabId,
- targetWorkspaceId: target.workspaceId,
- targetPane: paneId,
- splitTarget: BrowserPaneSplitTarget(orientation: .horizontal, insertFirst: false)
- )
- )
- }
-
- func testDecodeTransferPayloadReadsTabAndSourcePane() {
- let tabId = UUID()
- let sourcePaneId = UUID()
- let payload = try! JSONSerialization.data(
- withJSONObject: [
- "tab": ["id": tabId.uuidString],
- "sourcePaneId": sourcePaneId.uuidString,
- "sourceProcessId": ProcessInfo.processInfo.processIdentifier,
- ]
- )
-
- let transfer = BrowserPaneDragTransfer.decode(from: payload)
-
- XCTAssertEqual(transfer?.tabId, tabId)
- XCTAssertEqual(transfer?.sourcePaneId, sourcePaneId)
- XCTAssertTrue(transfer?.isFromCurrentProcess == true)
- }
-}
-
-@MainActor
-final class WindowBrowserSlotViewTests: XCTestCase {
- private final class CapturingView: NSView {
- override func hitTest(_ point: NSPoint) -> NSView? {
- bounds.contains(point) ? self : nil
- }
- }
-
- private func advanceAnimations() {
- RunLoop.current.run(until: Date().addingTimeInterval(0.25))
- }
-
- func testDropZoneOverlayStaysAboveContentWithoutBlockingHits() {
- let container = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 100))
- let slot = WindowBrowserSlotView(frame: container.bounds)
- container.addSubview(slot)
- let child = CapturingView(frame: slot.bounds)
- child.autoresizingMask = [.width, .height]
- slot.addSubview(child)
-
- slot.setDropZoneOverlay(zone: .right)
- container.layoutSubtreeIfNeeded()
-
- guard let overlay = container.subviews.first(where: {
- $0 !== slot && String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView")
- }) else {
- XCTFail("Expected browser slot drop-zone overlay")
- return
- }
-
- XCTAssertTrue(container.subviews.last === overlay, "Overlay should stay above the hosted web view")
- XCTAssertFalse(overlay.isHidden)
- XCTAssertEqual(overlay.frame.origin.x, 100, accuracy: 0.5)
- XCTAssertEqual(overlay.frame.origin.y, 4, accuracy: 0.5)
- XCTAssertEqual(overlay.frame.size.width, 96, accuracy: 0.5)
- XCTAssertEqual(overlay.frame.size.height, 92, accuracy: 0.5)
- XCTAssertNil(overlay.hitTest(NSPoint(x: 120, y: 50)), "Overlay should never intercept pointer hits")
- XCTAssertTrue(slot.hitTest(NSPoint(x: 120, y: 50)) === child)
-
- slot.setDropZoneOverlay(zone: nil)
- advanceAnimations()
- XCTAssertTrue(overlay.isHidden, "Clearing the drop zone should hide the overlay")
- }
-
- func testTopDropZoneOverlayUsesFullBrowserContentHeight() {
- let container = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 100))
- let slot = WindowBrowserSlotView(frame: container.bounds)
- container.addSubview(slot)
-
- slot.setPaneTopChromeHeight(20)
- slot.setDropZoneOverlay(zone: .top)
- container.layoutSubtreeIfNeeded()
-
- guard let overlay = container.subviews.first(where: {
- String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView")
- }) else {
- XCTFail("Expected browser slot drop-zone overlay")
- return
- }
-
- XCTAssertFalse(overlay.isHidden)
- XCTAssertEqual(overlay.frame.origin.x, 4, accuracy: 0.5)
- XCTAssertEqual(overlay.frame.origin.y, 60, accuracy: 0.5)
- XCTAssertEqual(overlay.frame.size.width, 192, accuracy: 0.5)
- XCTAssertEqual(overlay.frame.size.height, 56, accuracy: 0.5)
- XCTAssertGreaterThan(overlay.frame.maxY, slot.frame.maxY)
- XCTAssertEqual(slot.layer?.masksToBounds, true)
-
- slot.setDropZoneOverlay(zone: nil)
- advanceAnimations()
- XCTAssertEqual(slot.layer?.masksToBounds, true)
- }
-}
-
-@MainActor
-final class WindowDragHandleHitTests: XCTestCase {
- private final class CapturingView: NSView {
- override func hitTest(_ point: NSPoint) -> NSView? {
- bounds.contains(point) ? self : nil
- }
- }
-
- private final class HostContainerView: NSView {}
- private final class BlockingTopHitContainerView: NSView {
- override func hitTest(_ point: NSPoint) -> NSView? {
- bounds.contains(point) ? self : nil
- }
- }
- private final class PassThroughProbeView: NSView {
- var onHitTest: (() -> Void)?
-
- override func hitTest(_ point: NSPoint) -> NSView? {
- guard bounds.contains(point) else { return nil }
- onHitTest?()
- return nil
- }
- }
- private final class PassiveHostContainerView: NSView {
- override func hitTest(_ point: NSPoint) -> NSView? {
- guard bounds.contains(point) else { return nil }
- return super.hitTest(point) ?? self
- }
- }
-
- private final class MutatingSiblingView: NSView {
- weak var container: NSView?
- private var didMutate = false
-
- override func hitTest(_ point: NSPoint) -> NSView? {
- guard bounds.contains(point) else { return nil }
- guard !didMutate, let container else { return nil }
- didMutate = true
- let transient = NSView(frame: .zero)
- container.addSubview(transient)
- transient.removeFromSuperview()
- return nil
- }
- }
-
- private final class ReentrantDragHandleView: NSView {
- override func hitTest(_ point: NSPoint) -> NSView? {
- let shouldCapture = windowDragHandleShouldCaptureHit(point, in: self, eventType: .leftMouseDown, eventWindow: self.window)
- return shouldCapture ? self : nil
- }
- }
-
- /// A sibling view whose hitTest re-enters windowDragHandleShouldCaptureHit,
- /// simulating the crash path where sibling.hitTest triggers a SwiftUI layout
- /// pass that calls back into the drag handle's hit resolution.
- private final class ReentrantSiblingView: NSView {
- weak var dragHandle: NSView?
- var reenteredResult: Bool?
-
- override func hitTest(_ point: NSPoint) -> NSView? {
- guard bounds.contains(point), let dragHandle else { return nil }
- // Simulate the re-entry: during sibling hit test, SwiftUI layout
- // calls windowDragHandleShouldCaptureHit on the drag handle again.
- reenteredResult = windowDragHandleShouldCaptureHit(
- point, in: dragHandle, eventType: .leftMouseDown, eventWindow: dragHandle.window
- )
- return nil
- }
- }
-
- func testDragHandleCapturesHitWhenNoSiblingClaimsPoint() {
- let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
- let dragHandle = NSView(frame: container.bounds)
- container.addSubview(dragHandle)
-
- XCTAssertTrue(
- windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle, eventType: .leftMouseDown),
- "Empty titlebar space should drag the window"
- )
- }
-
- func testDragHandleYieldsWhenSiblingClaimsPoint() {
- let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
- let dragHandle = NSView(frame: container.bounds)
- container.addSubview(dragHandle)
-
- let folderIconHost = CapturingView(frame: NSRect(x: 10, y: 10, width: 16, height: 16))
- container.addSubview(folderIconHost)
-
- XCTAssertFalse(
- windowDragHandleShouldCaptureHit(NSPoint(x: 14, y: 14), in: dragHandle, eventType: .leftMouseDown),
- "Interactive titlebar controls should receive the mouse event"
- )
- XCTAssertTrue(windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle, eventType: .leftMouseDown))
- }
-
- func testDragHandleIgnoresHiddenSiblingWhenResolvingHit() {
- let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
- let dragHandle = NSView(frame: container.bounds)
- container.addSubview(dragHandle)
-
- let hidden = CapturingView(frame: NSRect(x: 10, y: 10, width: 16, height: 16))
- hidden.isHidden = true
- container.addSubview(hidden)
-
- XCTAssertTrue(windowDragHandleShouldCaptureHit(NSPoint(x: 14, y: 14), in: dragHandle, eventType: .leftMouseDown))
- }
-
- func testDragHandleDoesNotCaptureOutsideBounds() {
- let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
- let dragHandle = NSView(frame: container.bounds)
- container.addSubview(dragHandle)
-
- XCTAssertFalse(windowDragHandleShouldCaptureHit(NSPoint(x: 240, y: 18), in: dragHandle, eventType: .leftMouseDown))
- }
-
- func testDragHandleSkipsCaptureForPassivePointerEvents() {
- let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
- let dragHandle = NSView(frame: container.bounds)
- container.addSubview(dragHandle)
-
- let point = NSPoint(x: 180, y: 18)
- XCTAssertFalse(windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: .mouseMoved))
- XCTAssertFalse(windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: .cursorUpdate))
- XCTAssertFalse(windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: nil))
- XCTAssertTrue(windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: .leftMouseDown))
- }
-
- func testDragHandleSkipsForeignLeftMouseDownDuringLaunch() {
- let point = NSPoint(x: 180, y: 18)
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 220, height: 36),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let container = NSView(frame: contentView.bounds)
- container.autoresizingMask = [.width, .height]
- contentView.addSubview(container)
-
- let dragHandle = NSView(frame: container.bounds)
- dragHandle.autoresizingMask = [.width, .height]
- container.addSubview(dragHandle)
-
- let foreignWindow = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 220, height: 36),
- styleMask: [.titled],
- backing: .buffered,
- defer: false
- )
- defer { foreignWindow.orderOut(nil) }
-
- XCTAssertFalse(
- windowDragHandleShouldCaptureHit(
- point,
- in: dragHandle,
- eventType: .leftMouseDown,
- eventWindow: nil
- ),
- "Launch activation events without a matching window should not trigger drag-handle hierarchy walk"
- )
-
- XCTAssertFalse(
- windowDragHandleShouldCaptureHit(
- point,
- in: dragHandle,
- eventType: .leftMouseDown,
- eventWindow: foreignWindow
- ),
- "Left mouse-down events for a different window should be treated as passive"
- )
-
- XCTAssertTrue(
- windowDragHandleShouldCaptureHit(
- point,
- in: dragHandle,
- eventType: .leftMouseDown,
- eventWindow: window
- ),
- "Left mouse-down events for this window should still capture empty titlebar space"
- )
- }
-
- func testPassiveHostingTopHitClassification() {
- XCTAssertTrue(windowDragHandleShouldTreatTopHitAsPassiveHost(HostContainerView(frame: .zero)))
- XCTAssertFalse(windowDragHandleShouldTreatTopHitAsPassiveHost(NSButton(frame: .zero)))
- }
-
- func testDragHandleIgnoresPassiveHostSiblingHit() {
- let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
- let dragHandle = NSView(frame: container.bounds)
- container.addSubview(dragHandle)
-
- let passiveHost = PassiveHostContainerView(frame: container.bounds)
- container.addSubview(passiveHost)
-
- XCTAssertTrue(
- windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle, eventType: .leftMouseDown),
- "Passive host wrappers should not block titlebar drag capture"
- )
- }
-
- func testDragHandleRespectsInteractiveChildInsidePassiveHost() {
- let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
- let dragHandle = NSView(frame: container.bounds)
- container.addSubview(dragHandle)
-
- let passiveHost = PassiveHostContainerView(frame: container.bounds)
- let folderControl = CapturingView(frame: NSRect(x: 10, y: 10, width: 16, height: 16))
- passiveHost.addSubview(folderControl)
- container.addSubview(passiveHost)
-
- XCTAssertFalse(
- windowDragHandleShouldCaptureHit(NSPoint(x: 14, y: 14), in: dragHandle, eventType: .leftMouseDown),
- "Interactive controls inside passive host wrappers should still receive hits"
- )
- }
-
- func testTopHitResolutionStateIsScopedPerWindow() {
- let point = NSPoint(x: 100, y: 18)
-
- let outerWindow = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 220, height: 36),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { outerWindow.orderOut(nil) }
- guard let outerContentView = outerWindow.contentView else {
- XCTFail("Expected outer content view")
- return
- }
- let outerContainer = NSView(frame: outerContentView.bounds)
- outerContainer.autoresizingMask = [.width, .height]
- outerContentView.addSubview(outerContainer)
- let outerDragHandle = NSView(frame: outerContainer.bounds)
- outerDragHandle.autoresizingMask = [.width, .height]
- outerContainer.addSubview(outerDragHandle)
-
- let nestedWindow = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 220, height: 36),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { nestedWindow.orderOut(nil) }
- guard let nestedContentView = nestedWindow.contentView else {
- XCTFail("Expected nested content view")
- return
- }
- let nestedContainer = BlockingTopHitContainerView(frame: nestedContentView.bounds)
- nestedContainer.autoresizingMask = [.width, .height]
- nestedContentView.addSubview(nestedContainer)
- let nestedDragHandle = NSView(frame: nestedContainer.bounds)
- nestedDragHandle.autoresizingMask = [.width, .height]
- nestedContainer.addSubview(nestedDragHandle)
-
- XCTAssertFalse(
- windowDragHandleShouldCaptureHit(point, in: nestedDragHandle, eventType: .leftMouseDown, eventWindow: nestedWindow),
- "Nested window drag handle should be blocked by top-hit titlebar container"
- )
-
- var nestedCaptureResult: Bool?
- let probe = PassThroughProbeView(frame: outerContainer.bounds)
- probe.autoresizingMask = [.width, .height]
- probe.onHitTest = {
- nestedCaptureResult = windowDragHandleShouldCaptureHit(point, in: nestedDragHandle, eventType: .leftMouseDown, eventWindow: nestedWindow)
- }
- outerContainer.addSubview(probe)
-
- _ = windowDragHandleShouldCaptureHit(point, in: outerDragHandle, eventType: .leftMouseDown, eventWindow: outerWindow)
-
- XCTAssertEqual(
- nestedCaptureResult,
- false,
- "Top-hit recursion in one window must not disable top-hit resolution in another window"
- )
- }
-
- func testDragHandleRemainsStableWhenSiblingMutatesSubviewsDuringHitTest() {
- let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
- let dragHandle = NSView(frame: container.bounds)
- container.addSubview(dragHandle)
-
- let mutatingSibling = MutatingSiblingView(frame: container.bounds)
- mutatingSibling.container = container
- container.addSubview(mutatingSibling)
-
- XCTAssertTrue(
- windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle, eventType: .leftMouseDown),
- "Subview mutations during hit testing should not crash or break drag-handle capture"
- )
- }
-
- func testDragHandleSiblingHitTestReentrancyDoesNotCrash() {
- let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
- let dragHandle = NSView(frame: container.bounds)
- container.addSubview(dragHandle)
-
- let reentrantSibling = ReentrantSiblingView(frame: container.bounds)
- reentrantSibling.dragHandle = dragHandle
- container.addSubview(reentrantSibling)
-
- // The outer call enters the sibling walk, which calls
- // reentrantSibling.hitTest(), which re-enters
- // windowDragHandleShouldCaptureHit. Without the re-entrancy guard
- // this would trigger a Swift exclusive-access violation (SIGABRT).
- let outerResult = windowDragHandleShouldCaptureHit(
- NSPoint(x: 110, y: 18), in: dragHandle, eventType: .leftMouseDown
- )
- XCTAssertTrue(outerResult, "Outer call should still capture when sibling returns nil")
- XCTAssertEqual(
- reentrantSibling.reenteredResult, false,
- "Re-entrant call should bail out (return false) instead of crashing"
- )
- }
-
- func testDragHandleTopHitResolutionSurvivesSameWindowReentrancy() {
- let point = NSPoint(x: 180, y: 18)
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 220, height: 36),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let container = NSView(frame: contentView.bounds)
- container.autoresizingMask = [.width, .height]
- contentView.addSubview(container)
-
- let dragHandle = ReentrantDragHandleView(frame: container.bounds)
- dragHandle.autoresizingMask = [.width, .height]
- container.addSubview(dragHandle)
-
- XCTAssertTrue(
- windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: .leftMouseDown, eventWindow: window),
- "Reentrant same-window top-hit resolution should not trigger exclusivity crashes"
- )
- }
-}
-
-#if DEBUG
-@MainActor
-final class SidebarWorkspaceShortcutHintMetricsTests: XCTestCase {
- override func setUp() {
- super.setUp()
- SidebarWorkspaceShortcutHintMetrics.resetCacheForTesting()
- }
-
- override func tearDown() {
- SidebarWorkspaceShortcutHintMetrics.resetCacheForTesting()
- super.tearDown()
- }
-
- func testHintWidthCachesRepeatedMeasurements() {
- XCTAssertEqual(SidebarWorkspaceShortcutHintMetrics.measurementCountForTesting(), 0)
-
- let first = SidebarWorkspaceShortcutHintMetrics.hintWidth(for: "⌘1")
- XCTAssertGreaterThan(first, 0)
- XCTAssertEqual(SidebarWorkspaceShortcutHintMetrics.measurementCountForTesting(), 1)
-
- let second = SidebarWorkspaceShortcutHintMetrics.hintWidth(for: "⌘1")
- XCTAssertEqual(second, first)
- XCTAssertEqual(SidebarWorkspaceShortcutHintMetrics.measurementCountForTesting(), 1)
-
- _ = SidebarWorkspaceShortcutHintMetrics.hintWidth(for: "⌘2")
- XCTAssertEqual(SidebarWorkspaceShortcutHintMetrics.measurementCountForTesting(), 2)
- }
-
- func testSlotWidthAppliesMinimumAndDebugInset() {
- let nilLabelWidth = SidebarWorkspaceShortcutHintMetrics.slotWidth(label: nil, debugXOffset: 999)
- XCTAssertEqual(nilLabelWidth, 28)
-
- let base = SidebarWorkspaceShortcutHintMetrics.slotWidth(label: "⌘1", debugXOffset: 0)
- let widened = SidebarWorkspaceShortcutHintMetrics.slotWidth(label: "⌘1", debugXOffset: 10)
- XCTAssertGreaterThan(widened, base)
- }
-}
-#endif
-
-@MainActor
-final class DraggableFolderHitTests: XCTestCase {
- func testFolderHitTestReturnsContainerWhenInsideBounds() {
- let folderView = DraggableFolderNSView(directory: "/tmp")
- folderView.frame = NSRect(x: 0, y: 0, width: 16, height: 16)
-
- guard let hit = folderView.hitTest(NSPoint(x: 8, y: 8)) else {
- XCTFail("Expected folder icon to capture inside hit")
- return
- }
- XCTAssertTrue(hit === folderView)
- }
-
- func testFolderHitTestReturnsNilOutsideBounds() {
- let folderView = DraggableFolderNSView(directory: "/tmp")
- folderView.frame = NSRect(x: 0, y: 0, width: 16, height: 16)
-
- XCTAssertNil(folderView.hitTest(NSPoint(x: 20, y: 8)))
- }
-
- func testFolderIconDisablesWindowMoveBehavior() {
- let folderView = DraggableFolderNSView(directory: "/tmp")
- XCTAssertFalse(folderView.mouseDownCanMoveWindow)
- }
-}
-
-@MainActor
-final class TitlebarLeadingInsetPassthroughViewTests: XCTestCase {
- func testLeadingInsetViewDoesNotParticipateInHitTesting() {
- let view = TitlebarLeadingInsetPassthroughView(frame: NSRect(x: 0, y: 0, width: 200, height: 40))
- XCTAssertNil(view.hitTest(NSPoint(x: 20, y: 10)))
- }
-
- func testLeadingInsetViewCannotMoveWindowViaMouseDown() {
- let view = TitlebarLeadingInsetPassthroughView(frame: NSRect(x: 0, y: 0, width: 200, height: 40))
- XCTAssertFalse(view.mouseDownCanMoveWindow)
- }
-}
-
-@MainActor
-final class FolderWindowMoveSuppressionTests: XCTestCase {
- private func makeWindow() -> NSWindow {
- NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 320, height: 180),
- styleMask: [.titled, .closable, .miniaturizable, .resizable],
- backing: .buffered,
- defer: false
- )
- }
-
- func testSuppressionDisablesMovableWindow() {
- let window = makeWindow()
- window.isMovable = true
-
- let previous = temporarilyDisableWindowDragging(window: window)
-
- XCTAssertEqual(previous, true)
- XCTAssertFalse(window.isMovable)
- }
-
- func testSuppressionPreservesAlreadyImmovableWindow() {
- let window = makeWindow()
- window.isMovable = false
-
- let previous = temporarilyDisableWindowDragging(window: window)
-
- XCTAssertEqual(previous, false)
- XCTAssertFalse(window.isMovable)
- }
-
- func testRestoreAppliesPreviousMovableState() {
- let window = makeWindow()
- window.isMovable = false
-
- restoreWindowDragging(window: window, previousMovableState: true)
- XCTAssertTrue(window.isMovable)
-
- restoreWindowDragging(window: window, previousMovableState: false)
- XCTAssertFalse(window.isMovable)
- }
-
- func testWindowDragSuppressionDepthLifecycle() {
- let window = makeWindow()
- XCTAssertEqual(windowDragSuppressionDepth(window: window), 0)
- XCTAssertFalse(isWindowDragSuppressed(window: window))
-
- XCTAssertEqual(beginWindowDragSuppression(window: window), 1)
- XCTAssertEqual(windowDragSuppressionDepth(window: window), 1)
- XCTAssertTrue(isWindowDragSuppressed(window: window))
-
- XCTAssertEqual(endWindowDragSuppression(window: window), 0)
- XCTAssertEqual(windowDragSuppressionDepth(window: window), 0)
- XCTAssertFalse(isWindowDragSuppressed(window: window))
- }
-
- func testWindowDragSuppressionIsReferenceCounted() {
- let window = makeWindow()
- XCTAssertEqual(beginWindowDragSuppression(window: window), 1)
- XCTAssertEqual(beginWindowDragSuppression(window: window), 2)
- XCTAssertEqual(windowDragSuppressionDepth(window: window), 2)
- XCTAssertTrue(isWindowDragSuppressed(window: window))
-
- XCTAssertEqual(endWindowDragSuppression(window: window), 1)
- XCTAssertEqual(windowDragSuppressionDepth(window: window), 1)
- XCTAssertTrue(isWindowDragSuppressed(window: window))
-
- XCTAssertEqual(endWindowDragSuppression(window: window), 0)
- XCTAssertEqual(windowDragSuppressionDepth(window: window), 0)
- XCTAssertFalse(isWindowDragSuppressed(window: window))
- }
-
- func testTemporaryWindowMovableEnableRestoresImmovableWindow() {
- let window = makeWindow()
- window.isMovable = false
-
- let previous = withTemporaryWindowMovableEnabled(window: window) {
- XCTAssertTrue(window.isMovable)
- }
-
- XCTAssertEqual(previous, false)
- XCTAssertFalse(window.isMovable)
- }
-
- func testTemporaryWindowMovableEnablePreservesMovableWindow() {
- let window = makeWindow()
- window.isMovable = true
-
- let previous = withTemporaryWindowMovableEnabled(window: window) {
- XCTAssertTrue(window.isMovable)
- }
-
- XCTAssertEqual(previous, true)
- XCTAssertTrue(window.isMovable)
- }
-}
-
-@MainActor
-final class WindowMoveSuppressionHitPathTests: XCTestCase {
- private func makeWindowWithContentView() -> (NSWindow, NSView) {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 320, height: 180),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- let contentView = NSView(frame: window.contentRect(forFrameRect: window.frame))
- window.contentView = contentView
- return (window, contentView)
- }
-
- private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent {
- guard let event = NSEvent.mouseEvent(
- with: type,
- location: location,
- modifierFlags: [],
- timestamp: ProcessInfo.processInfo.systemUptime,
- windowNumber: window.windowNumber,
- context: nil,
- eventNumber: 0,
- clickCount: 1,
- pressure: 1.0
- ) else {
- fatalError("Failed to create \(type) mouse event")
- }
- return event
- }
-
- func testSuppressionHitPathRecognizesFolderView() {
- let folderView = DraggableFolderNSView(directory: "/tmp")
- XCTAssertTrue(shouldSuppressWindowMoveForFolderDrag(hitView: folderView))
- }
-
- func testSuppressionHitPathRecognizesDescendantOfFolderView() {
- let folderView = DraggableFolderNSView(directory: "/tmp")
- let child = NSView(frame: .zero)
- folderView.addSubview(child)
- XCTAssertTrue(shouldSuppressWindowMoveForFolderDrag(hitView: child))
- }
-
- func testSuppressionHitPathIgnoresUnrelatedViews() {
- XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(hitView: NSView(frame: .zero)))
- XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(hitView: nil))
- }
-
- func testSuppressionEventPathRecognizesFolderHitInsideWindow() {
- let (window, contentView) = makeWindowWithContentView()
- window.isMovable = true
- let folderView = DraggableFolderNSView(directory: "/tmp")
- folderView.frame = NSRect(x: 10, y: 10, width: 16, height: 16)
- contentView.addSubview(folderView)
-
- let event = makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 14, y: 14), window: window)
-
- XCTAssertTrue(shouldSuppressWindowMoveForFolderDrag(window: window, event: event))
- }
-
- func testSuppressionEventPathRejectsNonFolderAndNonMouseDownEvents() {
- let (window, contentView) = makeWindowWithContentView()
- window.isMovable = true
- let plainView = NSView(frame: NSRect(x: 0, y: 0, width: 40, height: 40))
- contentView.addSubview(plainView)
-
- let down = makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 20, y: 20), window: window)
- XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(window: window, event: down))
-
- let dragged = makeMouseEvent(type: .leftMouseDragged, location: NSPoint(x: 20, y: 20), window: window)
- XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(window: window, event: dragged))
- }
-}
-
-@MainActor
-final class CommandPaletteOverlayPromotionPolicyTests: XCTestCase {
- func testShouldPromoteWhenBecomingVisible() {
- XCTAssertTrue(
- CommandPaletteOverlayPromotionPolicy.shouldPromote(
- previouslyVisible: false,
- isVisible: true
- )
- )
- }
-
- func testShouldNotPromoteWhenAlreadyVisible() {
- XCTAssertFalse(
- CommandPaletteOverlayPromotionPolicy.shouldPromote(
- previouslyVisible: true,
- isVisible: true
- )
- )
- }
-
- func testShouldNotPromoteWhenHidden() {
- XCTAssertFalse(
- CommandPaletteOverlayPromotionPolicy.shouldPromote(
- previouslyVisible: true,
- isVisible: false
- )
- )
- XCTAssertFalse(
- CommandPaletteOverlayPromotionPolicy.shouldPromote(
- previouslyVisible: false,
- isVisible: false
- )
- )
- }
-}
-
-@MainActor
-final class GhosttySurfaceOverlayTests: XCTestCase {
- private final class ScrollProbeSurfaceView: GhosttyNSView {
- private(set) var scrollWheelCallCount = 0
-
- override func scrollWheel(with event: NSEvent) {
- scrollWheelCallCount += 1
- }
- }
-
- private func findEditableTextField(in view: NSView) -> NSTextField? {
- if let field = view as? NSTextField, field.isEditable {
- return field
- }
- for subview in view.subviews {
- if let field = findEditableTextField(in: subview) {
- return field
- }
- }
- return nil
- }
-
- private func firstResponderOwnsTextField(_ firstResponder: NSResponder?, textField: NSTextField) -> Bool {
- if firstResponder === textField {
- return true
- }
- if let editor = firstResponder as? NSTextView,
- editor.isFieldEditor,
- editor.delegate as? NSTextField === textField {
- return true
- }
- return false
- }
-
- func testTrackpadScrollRoutesToTerminalSurfaceAndPreservesKeyboardFocusPath() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
-
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let surfaceView = ScrollProbeSurfaceView(frame: NSRect(x: 0, y: 0, width: 160, height: 120))
- let hostedView = GhosttySurfaceScrollView(surfaceView: surfaceView)
- hostedView.frame = contentView.bounds
- hostedView.autoresizingMask = [.width, .height]
- contentView.addSubview(hostedView)
-
- window.makeKeyAndOrderFront(nil)
- window.displayIfNeeded()
- contentView.layoutSubtreeIfNeeded()
- RunLoop.current.run(until: Date().addingTimeInterval(0.05))
-
- guard let scrollView = hostedView.subviews.first(where: { $0 is NSScrollView }) as? NSScrollView else {
- XCTFail("Expected hosted terminal scroll view")
- return
- }
- XCTAssertFalse(
- scrollView.acceptsFirstResponder,
- "Host scroll view should not become first responder and steal terminal shortcuts"
- )
-
- _ = window.makeFirstResponder(nil)
-
- guard let cgEvent = CGEvent(
- scrollWheelEvent2Source: nil,
- units: .pixel,
- wheelCount: 2,
- wheel1: 0,
- wheel2: -12,
- wheel3: 0
- ), let scrollEvent = NSEvent(cgEvent: cgEvent) else {
- XCTFail("Expected scroll wheel event")
- return
- }
-
- scrollView.scrollWheel(with: scrollEvent)
-
- XCTAssertEqual(
- surfaceView.scrollWheelCallCount,
- 1,
- "Trackpad wheel events should be forwarded directly to Ghostty surface scrolling"
- )
- XCTAssertTrue(
- window.firstResponder === surfaceView,
- "Scroll wheel handling should keep keyboard focus on terminal surface"
- )
- }
-
- func testInactiveOverlayVisibilityTracksRequestedState() {
- let hostedView = GhosttySurfaceScrollView(
- surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 80, height: 50))
- )
-
- hostedView.setInactiveOverlay(color: .black, opacity: 0.35, visible: true)
- var state = hostedView.debugInactiveOverlayState()
- XCTAssertFalse(state.isHidden)
- XCTAssertEqual(state.alpha, 0.35, accuracy: 0.01)
-
- hostedView.setInactiveOverlay(color: .black, opacity: 0.35, visible: false)
- state = hostedView.debugInactiveOverlayState()
- XCTAssertTrue(state.isHidden)
- }
-
- func testWindowResignKeyClearsFocusedTerminalFirstResponder() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
-
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let hostedView = GhosttySurfaceScrollView(
- surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 160, height: 120))
- )
- hostedView.frame = contentView.bounds
- hostedView.autoresizingMask = [.width, .height]
- contentView.addSubview(hostedView)
-
- window.makeKeyAndOrderFront(nil)
- window.displayIfNeeded()
- contentView.layoutSubtreeIfNeeded()
- RunLoop.current.run(until: Date().addingTimeInterval(0.05))
-
- hostedView.setVisibleInUI(true)
- hostedView.setActive(true)
- hostedView.moveFocus()
- RunLoop.current.run(until: Date().addingTimeInterval(0.05))
- XCTAssertTrue(
- hostedView.isSurfaceViewFirstResponder(),
- "Expected terminal surface to be first responder before window blur"
- )
-
- NotificationCenter.default.post(name: NSWindow.didResignKeyNotification, object: window)
- RunLoop.current.run(until: Date().addingTimeInterval(0.05))
-
- XCTAssertFalse(
- hostedView.isSurfaceViewFirstResponder(),
- "Window blur should force terminal surface to resign first responder"
- )
- }
-
- func testSearchOverlayMountsAndUnmountsWithSearchState() {
- let surface = TerminalSurface(
- tabId: UUID(),
- context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
- configTemplate: nil,
- workingDirectory: nil
- )
- let hostedView = surface.hostedView
- XCTAssertFalse(hostedView.debugHasSearchOverlay())
-
- let searchState = TerminalSurface.SearchState(needle: "example")
- hostedView.setSearchOverlay(searchState: searchState)
- RunLoop.current.run(until: Date().addingTimeInterval(0.05))
- XCTAssertTrue(hostedView.debugHasSearchOverlay())
-
- hostedView.setSearchOverlay(searchState: nil)
- RunLoop.current.run(until: Date().addingTimeInterval(0.05))
- XCTAssertFalse(hostedView.debugHasSearchOverlay())
- }
-
- func testRapidSearchOverlayToggleDoesNotLeaveStaleOverlayMounted() {
- let surface = TerminalSurface(
- tabId: UUID(),
- context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
- configTemplate: nil,
- workingDirectory: nil
- )
- let hostedView = surface.hostedView
-
- hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "example"))
- hostedView.setSearchOverlay(searchState: nil)
- RunLoop.current.run(until: Date().addingTimeInterval(0.05))
-
- XCTAssertFalse(
- hostedView.debugHasSearchOverlay(),
- "A stale deferred mount must not resurrect the find overlay after it closes"
- )
- }
-
- func testSearchOverlayFocusesSearchFieldAfterDeferredAttach() {
- let surface = TerminalSurface(
- tabId: UUID(),
- context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
- configTemplate: nil,
- workingDirectory: nil
- )
- let hostedView = surface.hostedView
-
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
-
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
- hostedView.frame = contentView.bounds
- hostedView.autoresizingMask = [.width, .height]
- contentView.addSubview(hostedView)
-
- window.makeKeyAndOrderFront(nil)
- window.displayIfNeeded()
- contentView.layoutSubtreeIfNeeded()
- hostedView.setVisibleInUI(true)
- hostedView.setActive(true)
-
- let searchState = TerminalSurface.SearchState(needle: "")
- surface.searchState = searchState
- hostedView.setSearchOverlay(searchState: searchState)
- RunLoop.current.run(until: Date().addingTimeInterval(0.05))
-
- guard let searchField = findEditableTextField(in: hostedView) else {
- XCTFail("Expected mounted find text field")
- return
- }
-
- XCTAssertTrue(
- firstResponderOwnsTextField(window.firstResponder, textField: searchField),
- "Deferred search overlay attach should still move focus into the find field"
- )
- }
-
- func testStartOrFocusTerminalSearchReusesExistingSearchState() {
- let surface = TerminalSurface(
- tabId: UUID(),
- context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
- configTemplate: nil,
- workingDirectory: nil
- )
- let existingSearchState = TerminalSurface.SearchState(needle: "existing")
- surface.searchState = existingSearchState
-
- var focusNotificationCount = 0
- XCTAssertTrue(
- startOrFocusTerminalSearch(surface) { _ in
- focusNotificationCount += 1
- }
- )
-
- XCTAssertTrue(surface.searchState === existingSearchState)
- XCTAssertEqual(
- focusNotificationCount,
- 1,
- "Re-triggering terminal Find should refocus the existing overlay without recreating state"
- )
- }
-
- func testEscapeDismissingFindOverlayDoesNotLeakEscapeKeyUpToTerminal() {
- _ = NSApplication.shared
-
- let surface = TerminalSurface(
- tabId: UUID(),
- context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
- configTemplate: nil,
- workingDirectory: nil
- )
- let hostedView = surface.hostedView
-
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer {
- GhosttyNSView.debugGhosttySurfaceKeyEventObserver = nil
- window.orderOut(nil)
- }
-
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
- hostedView.frame = contentView.bounds
- hostedView.autoresizingMask = [.width, .height]
- contentView.addSubview(hostedView)
-
- window.makeKeyAndOrderFront(nil)
- window.displayIfNeeded()
- contentView.layoutSubtreeIfNeeded()
- hostedView.setVisibleInUI(true)
- hostedView.setActive(true)
- RunLoop.current.run(until: Date().addingTimeInterval(0.05))
-
- let searchState = TerminalSurface.SearchState(needle: "")
- surface.searchState = searchState
- hostedView.setSearchOverlay(searchState: searchState)
- RunLoop.current.run(until: Date().addingTimeInterval(0.05))
-
- guard let searchField = findEditableTextField(in: hostedView) else {
- XCTFail("Expected mounted find text field")
- return
- }
- window.makeFirstResponder(searchField)
-
- var escapeKeyUpCount = 0
- GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in
- guard keyEvent.action == GHOSTTY_ACTION_RELEASE, keyEvent.keycode == 53 else { return }
- escapeKeyUpCount += 1
- }
-
- let timestamp = ProcessInfo.processInfo.systemUptime
- guard let escapeKeyDown = NSEvent.keyEvent(
- with: .keyDown,
- location: .zero,
- modifierFlags: [],
- timestamp: timestamp,
- windowNumber: window.windowNumber,
- context: nil,
- characters: "\u{1b}",
- charactersIgnoringModifiers: "\u{1b}",
- isARepeat: false,
- keyCode: 53
- ), let escapeKeyUp = NSEvent.keyEvent(
- with: .keyUp,
- location: .zero,
- modifierFlags: [],
- timestamp: timestamp + 0.001,
- windowNumber: window.windowNumber,
- context: nil,
- characters: "\u{1b}",
- charactersIgnoringModifiers: "\u{1b}",
- isARepeat: false,
- keyCode: 53
- ) else {
- XCTFail("Failed to construct Escape key events")
- return
- }
-
- NSApp.sendEvent(escapeKeyDown)
- NSApp.sendEvent(escapeKeyUp)
- RunLoop.current.run(until: Date().addingTimeInterval(0.05))
-
- XCTAssertNil(surface.searchState, "Escape should dismiss find overlay when search text is empty")
- XCTAssertEqual(
- escapeKeyUpCount,
- 0,
- "Escape used to dismiss find overlay must not pass through to the terminal key-up path"
- )
- }
-
- @MainActor
- func testKeyboardCopyModeIndicatorMountsAndUnmounts() {
- let surface = TerminalSurface(
- tabId: UUID(),
- context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
- configTemplate: nil,
- workingDirectory: nil
- )
- let hostedView = surface.hostedView
- XCTAssertFalse(hostedView.debugHasKeyboardCopyModeIndicator())
-
- hostedView.syncKeyStateIndicator(text: "vim")
- XCTAssertTrue(hostedView.debugHasKeyboardCopyModeIndicator())
-
- hostedView.syncKeyStateIndicator(text: nil)
- XCTAssertFalse(hostedView.debugHasKeyboardCopyModeIndicator())
- }
-
- @MainActor
- func testDropHoverOverlayAttachesToParentContainerInsteadOfHostedTerminalView() {
- let container = NSView(frame: NSRect(x: 0, y: 0, width: 240, height: 120))
- let surfaceView = GhosttyNSView(frame: .zero)
- let hostedView = GhosttySurfaceScrollView(surfaceView: surfaceView)
- hostedView.frame = container.bounds
- container.addSubview(hostedView)
-
- hostedView.setDropZoneOverlay(zone: .right)
- container.layoutSubtreeIfNeeded()
-
- let state = hostedView.debugDropZoneOverlayState()
- XCTAssertFalse(state.isHidden)
- XCTAssertFalse(
- state.isAttachedToHostedView,
- "Drop-hover overlay should be mounted outside the hosted terminal view"
- )
- XCTAssertTrue(
- state.isAttachedToParentContainer,
- "Drop-hover overlay should be mounted in the parent container so it cannot perturb terminal layout"
- )
- XCTAssertEqual(state.frame.origin.x, 120, accuracy: 0.5)
- XCTAssertEqual(state.frame.origin.y, 4, accuracy: 0.5)
- XCTAssertEqual(state.frame.size.width, 116, accuracy: 0.5)
- XCTAssertEqual(state.frame.size.height, 112, accuracy: 0.5)
-
- hostedView.setDropZoneOverlay(zone: nil)
- RunLoop.current.run(until: Date().addingTimeInterval(0.25))
- XCTAssertTrue(hostedView.debugDropZoneOverlayState().isHidden)
- }
-
- func testForceRefreshNoopsAfterSurfaceReleaseDuringGeometryReconcile() throws {
-#if DEBUG
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 420, height: 280),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
-
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let surface = TerminalSurface(
- tabId: UUID(),
- context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
- configTemplate: nil,
- workingDirectory: nil
- )
- let hostedView = surface.hostedView
- hostedView.frame = contentView.bounds
- hostedView.autoresizingMask = [.width, .height]
- contentView.addSubview(hostedView)
-
- window.makeKeyAndOrderFront(nil)
- window.displayIfNeeded()
- contentView.layoutSubtreeIfNeeded()
- RunLoop.current.run(until: Date().addingTimeInterval(0.05))
-
- hostedView.reconcileGeometryNow()
- surface.releaseSurfaceForTesting()
- XCTAssertNil(surface.surface, "Surface should be nil after test release helper")
-
- hostedView.reconcileGeometryNow()
- surface.forceRefresh()
- XCTAssertNil(surface.surface, "Force refresh should no-op when runtime surface is nil")
-#else
- throw XCTSkip("Debug-only regression test")
-#endif
- }
-
- func testSearchOverlayMountDoesNotRetainTerminalSurface() {
- weak var weakSurface: TerminalSurface?
-
- let hostedView: GhosttySurfaceScrollView = {
- let surface = TerminalSurface(
- tabId: UUID(),
- context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
- configTemplate: nil,
- workingDirectory: nil
- )
- weakSurface = surface
- let hostedView = surface.hostedView
- hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "retain-check"))
- return hostedView
- }()
-
- RunLoop.main.run(until: Date().addingTimeInterval(0.01))
- XCTAssertTrue(hostedView.debugHasSearchOverlay())
- XCTAssertNil(weakSurface, "Mounted search overlay must not retain TerminalSurface")
- }
-
- func testSearchOverlaySurvivesPortalRebindDuringSplitLikeChurn() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 480, height: 320),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- let portal = WindowTerminalPortal(window: window)
-
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let anchorA = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 140))
- let anchorB = NSView(frame: NSRect(x: 220, y: 20, width: 180, height: 140))
- contentView.addSubview(anchorA)
- contentView.addSubview(anchorB)
-
- let surface = TerminalSurface(
- tabId: UUID(),
- context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
- configTemplate: nil,
- workingDirectory: nil
- )
- let hostedView = surface.hostedView
- hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "split"))
- RunLoop.current.run(until: Date().addingTimeInterval(0.05))
- XCTAssertTrue(hostedView.debugHasSearchOverlay())
-
- portal.bind(hostedView: hostedView, to: anchorA, visibleInUI: true)
- XCTAssertTrue(hostedView.debugHasSearchOverlay())
-
- portal.bind(hostedView: hostedView, to: anchorB, visibleInUI: true)
- XCTAssertTrue(
- hostedView.debugHasSearchOverlay(),
- "Split-like anchor churn should not unmount terminal search overlay"
- )
- }
-
- func testSearchOverlaySurvivesPortalVisibilityToggleDuringWorkspaceSwitchLikeChurn() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 480, height: 320),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- let portal = WindowTerminalPortal(window: window)
-
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let anchor = NSView(frame: NSRect(x: 40, y: 40, width: 220, height: 160))
- contentView.addSubview(anchor)
-
- let surface = TerminalSurface(
- tabId: UUID(),
- context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
- configTemplate: nil,
- workingDirectory: nil
- )
- let hostedView = surface.hostedView
- hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "workspace"))
- RunLoop.current.run(until: Date().addingTimeInterval(0.05))
- XCTAssertTrue(hostedView.debugHasSearchOverlay())
-
- portal.bind(hostedView: hostedView, to: anchor, visibleInUI: true)
- XCTAssertTrue(hostedView.debugHasSearchOverlay())
-
- portal.bind(hostedView: hostedView, to: anchor, visibleInUI: false)
- XCTAssertTrue(hostedView.debugHasSearchOverlay())
-
- portal.bind(hostedView: hostedView, to: anchor, visibleInUI: true)
- XCTAssertTrue(
- hostedView.debugHasSearchOverlay(),
- "Workspace-switch-like visibility toggles should not unmount terminal search overlay"
- )
- }
-}
-
-@MainActor
-final class TerminalWindowPortalLifecycleTests: XCTestCase {
- private final class ContentViewCountingWindow: NSWindow {
- var contentViewReadCount = 0
-
- override var contentView: NSView? {
- get {
- contentViewReadCount += 1
- return super.contentView
- }
- set {
- super.contentView = newValue
- }
- }
- }
-
- private func realizeWindowLayout(_ window: NSWindow) {
- window.makeKeyAndOrderFront(nil)
- window.displayIfNeeded()
- window.contentView?.layoutSubtreeIfNeeded()
- RunLoop.current.run(until: Date().addingTimeInterval(0.05))
- window.contentView?.layoutSubtreeIfNeeded()
- }
-
- func testPortalHostInstallsAboveContentViewForVisibility() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- let portal = WindowTerminalPortal(window: window)
- _ = portal.viewAtWindowPoint(NSPoint(x: 1, y: 1))
-
- guard let contentView = window.contentView,
- let container = contentView.superview else {
- XCTFail("Expected content container")
- return
- }
-
- guard let hostIndex = container.subviews.firstIndex(where: { $0 is WindowTerminalHostView }),
- let contentIndex = container.subviews.firstIndex(where: { $0 === contentView }) else {
- XCTFail("Expected host/content views in same container")
- return
- }
-
- XCTAssertGreaterThan(
- hostIndex,
- contentIndex,
- "Portal host must remain above content view so portal-hosted terminals stay visible"
- )
- }
-
- func testTerminalPortalHostStaysBelowBrowserPortalHostWhenBothAreInstalled() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- realizeWindowLayout(window)
-
- let browserPortal = WindowBrowserPortal(window: window)
- let terminalPortal = WindowTerminalPortal(window: window)
- _ = browserPortal.webViewAtWindowPoint(NSPoint(x: 1, y: 1))
- _ = terminalPortal.viewAtWindowPoint(NSPoint(x: 1, y: 1))
-
- guard let contentView = window.contentView,
- let container = contentView.superview else {
- XCTFail("Expected content container")
- return
- }
-
- func assertHostOrder(_ message: String) {
- guard let terminalHostIndex = container.subviews.firstIndex(where: { $0 is WindowTerminalHostView }),
- let browserHostIndex = container.subviews.firstIndex(where: { $0 is WindowBrowserHostView }) else {
- XCTFail("Expected both portal hosts in same container")
- return
- }
-
- XCTAssertLessThan(
- terminalHostIndex,
- browserHostIndex,
- message
- )
- }
-
- assertHostOrder("Terminal portal host should start below browser portal host")
-
- let anchor = NSView(frame: NSRect(x: 24, y: 24, width: 220, height: 150))
- contentView.addSubview(anchor)
- let hosted = GhosttySurfaceScrollView(
- surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80))
- )
- terminalPortal.bind(hostedView: hosted, to: anchor, visibleInUI: true)
- terminalPortal.synchronizeHostedViewForAnchor(anchor)
-
- assertHostOrder("Terminal portal bind/sync should not rise above the browser portal host")
- }
-
- func testRegistryPrunesPortalWhenWindowCloses() {
- let baseline = TerminalWindowPortalRegistry.debugPortalCount()
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
-
- _ = TerminalWindowPortalRegistry.viewAtWindowPoint(NSPoint(x: 1, y: 1), in: window)
- XCTAssertEqual(TerminalWindowPortalRegistry.debugPortalCount(), baseline + 1)
-
- NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window)
- XCTAssertEqual(TerminalWindowPortalRegistry.debugPortalCount(), baseline)
- }
-
- func testPruneDeadEntriesDetachesAnchorlessHostedView() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- let portal = WindowTerminalPortal(window: window)
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let hosted1 = GhosttySurfaceScrollView(
- surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 40, height: 30))
- )
-
- var anchor1: NSView? = NSView(frame: NSRect(x: 20, y: 20, width: 120, height: 80))
- contentView.addSubview(anchor1!)
- portal.bind(hostedView: hosted1, to: anchor1!, visibleInUI: true)
-
- anchor1?.removeFromSuperview()
- anchor1 = nil
-
- let hosted2 = GhosttySurfaceScrollView(
- surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 40, height: 30))
- )
- let anchor2 = NSView(frame: NSRect(x: 180, y: 20, width: 120, height: 80))
- contentView.addSubview(anchor2)
- portal.bind(hostedView: hosted2, to: anchor2, visibleInUI: true)
-
- XCTAssertEqual(portal.debugEntryCount(), 1, "Only the live anchored hosted view should remain tracked")
- XCTAssertEqual(portal.debugHostedSubviewCount(), 1, "Stale anchorless hosted views should be detached from hostView")
- }
-
- func testSynchronizeReusesInstalledTargetWithoutRepeatedContentViewLookup() {
- let window = ContentViewCountingWindow(
- contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- let portal = WindowTerminalPortal(window: window)
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let anchor = NSView(frame: NSRect(x: 40, y: 50, width: 200, height: 120))
- contentView.addSubview(anchor)
- let hosted = GhosttySurfaceScrollView(
- surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 100, height: 80))
- )
- portal.bind(hostedView: hosted, to: anchor, visibleInUI: true)
-
- let baselineReads = window.contentViewReadCount
- for _ in 0..<25 {
- portal.synchronizeHostedViewForAnchor(anchor)
- }
-
- XCTAssertEqual(
- window.contentViewReadCount,
- baselineReads,
- "Repeated synchronize calls should reuse installed target instead of repeatedly reading window.contentView"
- )
- }
-
- func testTerminalViewAtWindowPointResolvesPortalHostedSurface() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- let portal = WindowTerminalPortal(window: window)
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let anchor = NSView(frame: NSRect(x: 40, y: 50, width: 200, height: 120))
- contentView.addSubview(anchor)
-
- let hosted = GhosttySurfaceScrollView(
- surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 100, height: 80))
- )
- portal.bind(hostedView: hosted, to: anchor, visibleInUI: true)
-
- let center = NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY)
- let windowPoint = anchor.convert(center, to: nil)
- XCTAssertNotNil(
- portal.terminalViewAtWindowPoint(windowPoint),
- "Portal hit-testing should resolve the terminal view for Finder file drops"
- )
- }
-
- func testVisibilityTransitionBringsHostedViewToFront() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- let portal = WindowTerminalPortal(window: window)
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let anchor1 = NSView(frame: NSRect(x: 20, y: 20, width: 220, height: 180))
- let anchor2 = NSView(frame: NSRect(x: 80, y: 60, width: 220, height: 180))
- contentView.addSubview(anchor1)
- contentView.addSubview(anchor2)
-
- let terminal1 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80))
- let hosted1 = GhosttySurfaceScrollView(surfaceView: terminal1)
- let terminal2 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80))
- let hosted2 = GhosttySurfaceScrollView(surfaceView: terminal2)
-
- portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true)
- portal.bind(hostedView: hosted2, to: anchor2, visibleInUI: true)
-
- let overlapInContent = NSPoint(x: 120, y: 100)
- let overlapInWindow = contentView.convert(overlapInContent, to: nil)
- XCTAssertTrue(
- portal.terminalViewAtWindowPoint(overlapInWindow) === terminal2,
- "Latest bind should be top-most before visibility transition"
- )
-
- portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: false)
- portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true)
- XCTAssertTrue(
- portal.terminalViewAtWindowPoint(overlapInWindow) === terminal1,
- "Becoming visible should refresh z-order for already-hosted view"
- )
- }
-
- func testPriorityIncreaseBringsHostedViewToFrontWithoutVisibilityToggle() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- let portal = WindowTerminalPortal(window: window)
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let anchor1 = NSView(frame: NSRect(x: 20, y: 20, width: 220, height: 180))
- let anchor2 = NSView(frame: NSRect(x: 80, y: 60, width: 220, height: 180))
- contentView.addSubview(anchor1)
- contentView.addSubview(anchor2)
-
- let terminal1 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80))
- let hosted1 = GhosttySurfaceScrollView(surfaceView: terminal1)
- let terminal2 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80))
- let hosted2 = GhosttySurfaceScrollView(surfaceView: terminal2)
-
- portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true, zPriority: 1)
- portal.bind(hostedView: hosted2, to: anchor2, visibleInUI: true, zPriority: 2)
-
- let overlapInContent = NSPoint(x: 120, y: 100)
- let overlapInWindow = contentView.convert(overlapInContent, to: nil)
- XCTAssertTrue(
- portal.terminalViewAtWindowPoint(overlapInWindow) === terminal2,
- "Higher-priority terminal should initially be top-most"
- )
-
- portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true, zPriority: 2)
- XCTAssertTrue(
- portal.terminalViewAtWindowPoint(overlapInWindow) === terminal1,
- "Promoting z-priority should bring an already-visible terminal to front"
- )
- }
-
- func testHiddenPortalDefersRevealUntilFrameHasUsableSize() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 700, height: 420),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
-
- let portal = WindowTerminalPortal(window: window)
- realizeWindowLayout(window)
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let anchor = NSView(frame: NSRect(x: 40, y: 40, width: 280, height: 220))
- contentView.addSubview(anchor)
-
- let hosted = GhosttySurfaceScrollView(
- surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80))
- )
- portal.bind(hostedView: hosted, to: anchor, visibleInUI: true)
- XCTAssertFalse(hosted.isHidden, "Healthy geometry should be visible")
-
- // Collapse to a tiny frame first.
- anchor.frame = NSRect(x: 160.5, y: 1037.0, width: 79.0, height: 0.0)
- portal.synchronizeHostedViewForAnchor(anchor)
- XCTAssertTrue(hosted.isHidden, "Tiny geometry should hide the portal-hosted terminal")
-
- // Then restore to a non-zero but still too-small frame. It should remain hidden.
- anchor.frame = NSRect(x: 160.9, y: 1026.5, width: 93.6, height: 10.3)
- portal.synchronizeHostedViewForAnchor(anchor)
- XCTAssertTrue(
- hosted.isHidden,
- "Portal should defer reveal until geometry reaches a usable size"
- )
-
- // Once the frame is large enough again, reveal should resume.
- anchor.frame = NSRect(x: 40, y: 40, width: 180, height: 40)
- portal.synchronizeHostedViewForAnchor(anchor)
- XCTAssertFalse(hosted.isHidden, "Portal should unhide after geometry is usable")
- }
-
- func testScheduledExternalGeometrySyncRefreshesAncestorLayoutShift() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 700, height: 420),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer {
- NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window)
- window.orderOut(nil)
- }
-
- realizeWindowLayout(window)
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let shiftedContainer = NSView(frame: NSRect(x: 120, y: 60, width: 220, height: 160))
- contentView.addSubview(shiftedContainer)
- let anchor = NSView(frame: NSRect(x: 24, y: 28, width: 72, height: 56))
- shiftedContainer.addSubview(anchor)
-
- let surface = TerminalSurface(
- tabId: UUID(),
- context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
- configTemplate: nil,
- workingDirectory: nil
- )
- let hosted = surface.hostedView
- TerminalWindowPortalRegistry.bind(
- hostedView: hosted,
- to: anchor,
- visibleInUI: true,
- expectedSurfaceId: surface.id,
- expectedGeneration: surface.portalBindingGeneration()
- )
- TerminalWindowPortalRegistry.synchronizeForAnchor(anchor)
-
- let anchorCenter = NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY)
- let originalWindowPoint = anchor.convert(anchorCenter, to: nil)
- XCTAssertNotNil(
- TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window),
- "Initial hit-testing should resolve the portal-hosted terminal at its original window position"
- )
-
- shiftedContainer.frame.origin.x += 96
- contentView.layoutSubtreeIfNeeded()
- window.displayIfNeeded()
-
- let shiftedWindowPoint = anchor.convert(anchorCenter, to: nil)
- XCTAssertNotEqual(originalWindowPoint.x, shiftedWindowPoint.x, accuracy: 0.5)
- XCTAssertNil(
- TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window),
- "Ancestor-only layout shifts should leave the portal stale until an external geometry sync runs"
- )
- XCTAssertNotNil(
- TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window),
- "Before the external geometry sync, hit-testing should still point at the stale portal location"
- )
-
- TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows()
- RunLoop.current.run(until: Date().addingTimeInterval(0.05))
-
- XCTAssertNil(
- TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window),
- "The stale portal position should be cleared after the scheduled external geometry sync"
- )
- XCTAssertNotNil(
- TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window),
- "The scheduled external geometry sync should move the portal-hosted terminal to the anchor's new window position"
- )
- }
-
- func testScheduledExternalGeometrySyncWaitsForQueuedLayoutShift() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 700, height: 420),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer {
- NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window)
- window.orderOut(nil)
- }
-
- let surface = TerminalSurface(
- tabId: UUID(),
- context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
- configTemplate: nil,
- workingDirectory: nil
- )
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let shiftedContainer = NSView(frame: NSRect(x: 40, y: 60, width: 260, height: 180))
- contentView.addSubview(shiftedContainer)
- let anchor = NSView(frame: NSRect(x: 0, y: 0, width: 260, height: 180))
- shiftedContainer.addSubview(anchor)
- let hosted = surface.hostedView
- TerminalWindowPortalRegistry.bind(
- hostedView: hosted,
- to: anchor,
- visibleInUI: true,
- expectedSurfaceId: surface.id,
- expectedGeneration: surface.portalBindingGeneration()
- )
- TerminalWindowPortalRegistry.synchronizeForAnchor(anchor)
-
- let anchorCenter = NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY)
- let originalWindowPoint = anchor.convert(anchorCenter, to: nil)
- let originalAnchorFrameInWindow = anchor.convert(anchor.bounds, to: nil)
- XCTAssertNotNil(
- TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window),
- "Initial hit-testing should resolve the portal-hosted terminal at its original window position"
- )
-
- TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows()
- DispatchQueue.main.async {
- shiftedContainer.frame.origin.x += 72
- contentView.layoutSubtreeIfNeeded()
- window.displayIfNeeded()
- }
-
- RunLoop.current.run(until: Date().addingTimeInterval(0.05))
-
- let shiftedAnchorFrameInWindow = anchor.convert(anchor.bounds, to: nil)
- XCTAssertGreaterThan(
- shiftedAnchorFrameInWindow.minX,
- originalAnchorFrameInWindow.minX + 1,
- "The queued layout shift should move the anchor to the right"
- )
- XCTAssertGreaterThan(
- shiftedAnchorFrameInWindow.maxX,
- originalAnchorFrameInWindow.maxX + 1,
- "The shifted anchor should expose a new trailing region outside the stale portal frame"
- )
- let retiredStaleWindowPoint = NSPoint(
- x: (originalAnchorFrameInWindow.minX + shiftedAnchorFrameInWindow.minX) / 2,
- y: shiftedAnchorFrameInWindow.midY
- )
- let shiftedWindowPoint = NSPoint(
- x: (originalAnchorFrameInWindow.maxX + shiftedAnchorFrameInWindow.maxX) / 2,
- y: shiftedAnchorFrameInWindow.midY
- )
- XCTAssertNil(
- TerminalWindowPortalRegistry.terminalViewAtWindowPoint(retiredStaleWindowPoint, in: window),
- "The queued external sync should wait until the later layout shift settles, clearing the stale portal location"
- )
- XCTAssertNotNil(
- TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window),
- "The delayed external sync should move the portal-hosted terminal to the queued layout shift position"
- )
- }
-}
-
-@MainActor
-final class BrowserWindowPortalLifecycleTests: XCTestCase {
- private final class TrackingPortalWebView: WKWebView {
- private(set) var displayIfNeededCount = 0
- private(set) var reattachRenderingStateCount = 0
-
- override func displayIfNeeded() {
- displayIfNeededCount += 1
- super.displayIfNeeded()
- }
-
- @objc(_enterInWindow)
- func cmuxUnitTestEnterInWindow() {
- reattachRenderingStateCount += 1
- }
-
- @objc(_endDeferringViewInWindowChangesSync)
- func cmuxUnitTestEndDeferringViewInWindowChangesSync() {
- reattachRenderingStateCount += 1
- }
- }
-
- private final class WKInspectorProbeView: NSView {}
-
- private func realizeWindowLayout(_ window: NSWindow) {
- window.makeKeyAndOrderFront(nil)
- window.displayIfNeeded()
- window.contentView?.layoutSubtreeIfNeeded()
- RunLoop.current.run(until: Date().addingTimeInterval(0.05))
- window.contentView?.layoutSubtreeIfNeeded()
- }
-
- private func advanceAnimations() {
- RunLoop.current.run(until: Date().addingTimeInterval(0.25))
- }
-
- private func dropZoneOverlay(in slot: WindowBrowserSlotView, excluding webView: WKWebView) -> NSView? {
- let candidates = slot.subviews + (slot.superview?.subviews ?? [])
- return candidates.first(where: {
- $0 !== slot &&
- $0 !== webView &&
- String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView")
- })
- }
-
- func testPortalHostInstallsAboveContentViewForVisibility() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- let portal = WindowBrowserPortal(window: window)
- _ = portal.webViewAtWindowPoint(NSPoint(x: 1, y: 1))
-
- guard let contentView = window.contentView,
- let container = contentView.superview else {
- XCTFail("Expected content container")
- return
- }
-
- guard let hostIndex = container.subviews.firstIndex(where: { $0 is WindowBrowserHostView }),
- let contentIndex = container.subviews.firstIndex(where: { $0 === contentView }) else {
- XCTFail("Expected host/content views in same container")
- return
- }
-
- XCTAssertGreaterThan(
- hostIndex,
- contentIndex,
- "Browser portal host must remain above content view so portal-hosted web views stay visible"
- )
- }
-
- func testBrowserPortalHostStaysAboveTerminalPortalHostDuringPortalChurn() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- realizeWindowLayout(window)
-
- let browserPortal = WindowBrowserPortal(window: window)
- let terminalPortal = WindowTerminalPortal(window: window)
- _ = browserPortal.webViewAtWindowPoint(NSPoint(x: 1, y: 1))
- _ = terminalPortal.viewAtWindowPoint(NSPoint(x: 1, y: 1))
-
- guard let contentView = window.contentView,
- let container = contentView.superview else {
- XCTFail("Expected content container")
- return
- }
-
- func assertHostOrder(_ message: String) {
- guard let browserHostIndex = container.subviews.firstIndex(where: { $0 is WindowBrowserHostView }),
- let terminalHostIndex = container.subviews.firstIndex(where: { $0 is WindowTerminalHostView }) else {
- XCTFail("Expected both portal hosts in same container")
- return
- }
-
- XCTAssertGreaterThan(
- browserHostIndex,
- terminalHostIndex,
- message
- )
- }
-
- assertHostOrder("Browser portal host should start above terminal portal host")
-
- let terminalAnchor = NSView(frame: NSRect(x: 20, y: 20, width: 200, height: 140))
- contentView.addSubview(terminalAnchor)
- let terminalHostedView = GhosttySurfaceScrollView(
- surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80))
- )
- terminalPortal.bind(hostedView: terminalHostedView, to: terminalAnchor, visibleInUI: true)
- terminalPortal.synchronizeHostedViewForAnchor(terminalAnchor)
- assertHostOrder("Terminal portal sync should not rise above the browser portal host")
-
- let browserAnchor = NSView(frame: NSRect(x: 240, y: 20, width: 220, height: 140))
- contentView.addSubview(browserAnchor)
- let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
- browserPortal.bind(webView: webView, to: browserAnchor, visibleInUI: true)
- browserPortal.synchronizeWebViewForAnchor(browserAnchor)
- assertHostOrder("Browser portal sync should keep browser panes above portal-hosted terminals")
- }
-
- func testAnchorRebindKeepsWebViewInStablePortalSuperview() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- realizeWindowLayout(window)
- let portal = WindowBrowserPortal(window: window)
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let anchor1 = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 120))
- let anchor2 = NSView(frame: NSRect(x: 240, y: 40, width: 180, height: 120))
- contentView.addSubview(anchor1)
- contentView.addSubview(anchor2)
-
- let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
- portal.bind(webView: webView, to: anchor1, visibleInUI: true)
- let firstSuperview = webView.superview
-
- XCTAssertNotNil(firstSuperview)
- XCTAssertTrue(firstSuperview is WindowBrowserSlotView)
-
- portal.bind(webView: webView, to: anchor2, visibleInUI: true)
- XCTAssertTrue(webView.superview === firstSuperview, "Anchor moves should not reparent the web view")
-
- contentView.layoutSubtreeIfNeeded()
- portal.synchronizeWebViewForAnchor(anchor2)
- guard let slot = webView.superview as? WindowBrowserSlotView,
- let host = slot.superview as? WindowBrowserHostView else {
- XCTFail("Expected browser slot + host views")
- return
- }
- let expectedFrame = host.convert(anchor2.bounds, from: anchor2)
- XCTAssertEqual(slot.frame.origin.x, expectedFrame.origin.x, accuracy: 0.5)
- XCTAssertEqual(slot.frame.origin.y, expectedFrame.origin.y, accuracy: 0.5)
- XCTAssertEqual(slot.frame.size.width, expectedFrame.size.width, accuracy: 0.5)
- XCTAssertEqual(slot.frame.size.height, expectedFrame.size.height, accuracy: 0.5)
- }
-
- func testPortalClampsWebViewFrameToHostBoundsWhenAnchorOverflowsSidebar() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- realizeWindowLayout(window)
- let portal = WindowBrowserPortal(window: window)
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- // Simulate a transient oversized anchor rect during split churn.
- let anchor = NSView(frame: NSRect(x: 120, y: 20, width: 260, height: 150))
- contentView.addSubview(anchor)
-
- let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
- portal.bind(webView: webView, to: anchor, visibleInUI: true)
- contentView.layoutSubtreeIfNeeded()
- portal.synchronizeWebViewForAnchor(anchor)
-
- guard let slot = webView.superview as? WindowBrowserSlotView else {
- XCTFail("Expected web view slot")
- return
- }
-
- XCTAssertFalse(slot.isHidden, "Partially visible browser anchor should stay visible")
- XCTAssertEqual(slot.frame.origin.x, 120, accuracy: 0.5)
- XCTAssertEqual(slot.frame.origin.y, 20, accuracy: 0.5)
- XCTAssertEqual(slot.frame.size.width, 200, accuracy: 0.5)
- XCTAssertEqual(slot.frame.size.height, 150, accuracy: 0.5)
- }
-
- func testPortalClipsAnchorFrameThroughAncestorBounds() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 480, height: 320),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- realizeWindowLayout(window)
- let portal = WindowBrowserPortal(window: window)
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let clipView = NSView(frame: NSRect(x: 60, y: 40, width: 150, height: 120))
- contentView.addSubview(clipView)
-
- // Simulate SwiftUI/AppKit reporting an anchor wider than the actual visible pane.
- let anchor = NSView(frame: NSRect(x: -30, y: 0, width: 220, height: 120))
- clipView.addSubview(anchor)
-
- let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
- portal.bind(webView: webView, to: anchor, visibleInUI: true)
- contentView.layoutSubtreeIfNeeded()
- clipView.layoutSubtreeIfNeeded()
- portal.synchronizeWebViewForAnchor(anchor)
-
- guard let slot = webView.superview as? WindowBrowserSlotView else {
- XCTFail("Expected browser slot")
- return
- }
-
- XCTAssertFalse(slot.isHidden, "Ancestor clipping should keep the browser visible in the real pane")
- XCTAssertEqual(slot.frame.origin.x, 60, accuracy: 0.5)
- XCTAssertEqual(slot.frame.origin.y, 40, accuracy: 0.5)
- XCTAssertEqual(slot.frame.size.width, 150, accuracy: 0.5)
- XCTAssertEqual(slot.frame.size.height, 120, accuracy: 0.5)
- }
-
- func testPortalSyncNormalizesOutOfBoundsWebFrame() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- realizeWindowLayout(window)
- let portal = WindowBrowserPortal(window: window)
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let anchor = NSView(frame: NSRect(x: 40, y: 20, width: 220, height: 160))
- contentView.addSubview(anchor)
-
- let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
- portal.bind(webView: webView, to: anchor, visibleInUI: true)
- contentView.layoutSubtreeIfNeeded()
- portal.synchronizeWebViewForAnchor(anchor)
-
- guard let slot = webView.superview as? WindowBrowserSlotView else {
- XCTFail("Expected browser slot")
- return
- }
-
- // Reproduce observed drift from logs where WebKit shifts/expands frame beyond slot bounds.
- webView.frame = NSRect(x: 0, y: 250, width: slot.bounds.width, height: slot.bounds.height)
- XCTAssertGreaterThan(webView.frame.maxY, slot.bounds.maxY)
-
- portal.synchronizeWebViewForAnchor(anchor)
- XCTAssertEqual(webView.frame.origin.x, slot.bounds.origin.x, accuracy: 0.5)
- XCTAssertEqual(webView.frame.origin.y, slot.bounds.origin.y, accuracy: 0.5)
- XCTAssertEqual(webView.frame.size.width, slot.bounds.size.width, accuracy: 0.5)
- XCTAssertEqual(webView.frame.size.height, slot.bounds.size.height, accuracy: 0.5)
- }
-
- func testPortalSlotPinPreservesSideDockedInspectorManagedWebViewFrameOnRehost() {
- let slot = WindowBrowserSlotView(frame: NSRect(x: 0, y: 0, width: 240, height: 160))
- let webView = CmuxWebView(frame: NSRect(x: 0, y: 0, width: 132, height: 160), configuration: WKWebViewConfiguration())
- let inspectorContainer = NSView(frame: NSRect(x: 132, y: 0, width: 108, height: 160))
- let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
- inspectorView.autoresizingMask = [.width, .height]
- inspectorContainer.addSubview(inspectorView)
- slot.addSubview(webView)
- slot.addSubview(inspectorContainer)
-
- webView.translatesAutoresizingMaskIntoConstraints = false
- webView.autoresizingMask = []
- slot.pinHostedWebView(webView)
-
- XCTAssertEqual(
- webView.frame.maxX,
- inspectorContainer.frame.minX,
- accuracy: 0.5,
- "Rehosting a portal-managed browser should preserve the WebKit-owned side inspector split"
- )
- XCTAssertLessThan(
- webView.frame.width,
- slot.bounds.width,
- "The page frame should stay narrower than the full slot while a side-docked inspector is present"
- )
- }
-
- func testPortalResizePreservesSideDockedInspectorManagedWebViewFrame() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 520, height: 320),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- realizeWindowLayout(window)
- let portal = WindowBrowserPortal(window: window)
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 260, height: 180))
- contentView.addSubview(anchor)
-
- let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
- portal.bind(webView: webView, to: anchor, visibleInUI: true)
- contentView.layoutSubtreeIfNeeded()
- portal.synchronizeWebViewForAnchor(anchor)
-
- guard let slot = webView.superview as? WindowBrowserSlotView else {
- XCTFail("Expected browser slot")
- return
- }
-
- let initialInspectorWidth: CGFloat = 110
- let inspectorContainer = NSView(
- frame: NSRect(
- x: slot.bounds.width - initialInspectorWidth,
- y: 0,
- width: initialInspectorWidth,
- height: slot.bounds.height
- )
- )
- inspectorContainer.autoresizingMask = [.minXMargin, .height]
- let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
- inspectorView.autoresizingMask = [.width, .height]
- inspectorContainer.addSubview(inspectorView)
- slot.addSubview(inspectorContainer)
-
- webView.frame = NSRect(
- x: 0,
- y: 0,
- width: slot.bounds.width - initialInspectorWidth,
- height: slot.bounds.height
- )
- webView.autoresizingMask = [.width, .height]
- slot.layoutSubtreeIfNeeded()
-
- anchor.frame = NSRect(x: 40, y: 24, width: 220, height: 180)
- contentView.layoutSubtreeIfNeeded()
- portal.synchronizeWebViewForAnchor(anchor)
-
- XCTAssertFalse(slot.isHidden, "Resizing the browser pane should keep the hosted browser visible")
- XCTAssertEqual(
- webView.frame.maxX,
- inspectorContainer.frame.minX,
- accuracy: 0.5,
- "Portal sync should preserve the side-docked inspector split instead of stretching the page back over the inspector"
- )
- XCTAssertLessThan(
- webView.frame.width,
- slot.bounds.width,
- "Side-docked inspector should still own part of the slot after pane resize"
- )
- }
-
- func testPortalAnchorResizeDoesNotForceHostedWebViewPresentationRefresh() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 520, height: 320),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- realizeWindowLayout(window)
- let portal = WindowBrowserPortal(window: window)
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 220, height: 160))
- contentView.addSubview(anchor)
-
- let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration())
- portal.bind(webView: webView, to: anchor, visibleInUI: true)
- contentView.layoutSubtreeIfNeeded()
- portal.synchronizeWebViewForAnchor(anchor)
- advanceAnimations()
-
- guard let slot = webView.superview as? WindowBrowserSlotView else {
- XCTFail("Expected browser slot")
- return
- }
-
- let initialDisplayCount = webView.displayIfNeededCount
- let initialReattachCount = webView.reattachRenderingStateCount
- anchor.frame = NSRect(x: 52, y: 30, width: 248, height: 178)
- contentView.layoutSubtreeIfNeeded()
- portal.synchronizeWebViewForAnchor(anchor)
- advanceAnimations()
-
- XCTAssertFalse(slot.isHidden, "Anchor resize should keep the portal-hosted browser visible")
- XCTAssertEqual(slot.frame.origin.x, 52, accuracy: 0.5)
- XCTAssertEqual(slot.frame.origin.y, 30, accuracy: 0.5)
- XCTAssertEqual(slot.frame.size.width, 248, accuracy: 0.5)
- XCTAssertEqual(slot.frame.size.height, 178, accuracy: 0.5)
- XCTAssertGreaterThan(
- webView.displayIfNeededCount,
- initialDisplayCount,
- "Pure anchor geometry updates should still repaint the hosted browser"
- )
- XCTAssertEqual(
- webView.reattachRenderingStateCount,
- initialReattachCount,
- "Pure anchor geometry updates should not trigger the WebKit reattach path"
- )
- }
-
- func testExternalSplitResizeDoesNotForceHostedWebViewPresentationRefresh() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 640, height: 360),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- realizeWindowLayout(window)
- let portal = WindowBrowserPortal(window: window)
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let splitView = NSSplitView(frame: contentView.bounds)
- splitView.autoresizingMask = [.width, .height]
- splitView.isVertical = true
-
- let leadingPane = NSView(
- frame: NSRect(x: 0, y: 0, width: 220, height: contentView.bounds.height)
- )
- leadingPane.autoresizingMask = [.height]
- let trailingPane = NSView(
- frame: NSRect(
- x: 221,
- y: 0,
- width: contentView.bounds.width - 221,
- height: contentView.bounds.height
- )
- )
- trailingPane.autoresizingMask = [.width, .height]
- splitView.addSubview(leadingPane)
- splitView.addSubview(trailingPane)
- contentView.addSubview(splitView)
- splitView.adjustSubviews()
-
- let anchor = NSView(frame: trailingPane.bounds.insetBy(dx: 12, dy: 12))
- anchor.autoresizingMask = [.width, .height]
- trailingPane.addSubview(anchor)
-
- let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration())
- portal.bind(webView: webView, to: anchor, visibleInUI: true)
- contentView.layoutSubtreeIfNeeded()
- portal.synchronizeWebViewForAnchor(anchor)
- advanceAnimations()
-
- guard let slot = webView.superview as? WindowBrowserSlotView else {
- XCTFail("Expected browser slot")
- return
- }
-
- let initialDisplayCount = webView.displayIfNeededCount
- let initialReattachCount = webView.reattachRenderingStateCount
- let initialWidth = slot.frame.width
-
- splitView.setPosition(280, ofDividerAt: 0)
- contentView.layoutSubtreeIfNeeded()
- NotificationCenter.default.post(name: NSSplitView.didResizeSubviewsNotification, object: splitView)
- advanceAnimations()
-
- XCTAssertFalse(slot.isHidden, "App split resize should keep the browser slot visible")
- XCTAssertLessThan(
- slot.frame.width,
- initialWidth,
- "Moving the app split divider should shrink the hosted browser slot"
- )
- XCTAssertGreaterThan(
- webView.displayIfNeededCount,
- initialDisplayCount,
- "External split resize should still repaint the hosted browser"
- )
- XCTAssertEqual(
- webView.reattachRenderingStateCount,
- initialReattachCount,
- "External split resize should not trigger the WebKit reattach path"
- )
- }
-
- func testPortalSyncRepairsBottomDockedInspectorOverflowedPageFrame() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 520, height: 320),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- realizeWindowLayout(window)
- let portal = WindowBrowserPortal(window: window)
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 260, height: 180))
- contentView.addSubview(anchor)
-
- let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
- portal.bind(webView: webView, to: anchor, visibleInUI: true)
- contentView.layoutSubtreeIfNeeded()
- portal.synchronizeWebViewForAnchor(anchor)
-
- guard let slot = webView.superview as? WindowBrowserSlotView else {
- XCTFail("Expected browser slot")
- return
- }
-
- let inspectorHeight: CGFloat = 84
- let inspectorContainer = NSView(
- frame: NSRect(x: 0, y: 0, width: slot.bounds.width, height: inspectorHeight)
- )
- inspectorContainer.autoresizingMask = [.width]
- let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
- inspectorView.autoresizingMask = [.width, .height]
- inspectorContainer.addSubview(inspectorView)
- slot.addSubview(inspectorContainer)
-
- webView.frame = NSRect(
- x: 0,
- y: inspectorHeight,
- width: slot.bounds.width,
- height: slot.bounds.height
- )
- webView.autoresizingMask = [.width, .height]
- slot.layoutSubtreeIfNeeded()
-
- portal.synchronizeWebViewForAnchor(anchor)
-
- XCTAssertFalse(slot.isHidden, "Portal sync should keep the hosted browser visible")
- XCTAssertEqual(
- webView.frame.minY,
- inspectorHeight,
- accuracy: 0.5,
- "Portal sync should keep the page viewport below a bottom-docked inspector instead of shifting the page upward"
- )
- XCTAssertEqual(
- webView.frame.height,
- slot.bounds.height - inspectorHeight,
- accuracy: 0.5,
- "Portal sync should shrink the page viewport to the space above a bottom-docked inspector"
- )
- XCTAssertEqual(
- webView.frame.maxY,
- slot.bounds.maxY,
- accuracy: 0.5,
- "The repaired page viewport should stay flush with the top edge of the slot"
- )
- }
-
- func testHidingBrowserSlotYieldsOwnedInspectorFirstResponder() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 520, height: 320),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- realizeWindowLayout(window)
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let slot = WindowBrowserSlotView(frame: NSRect(x: 40, y: 24, width: 260, height: 180))
- contentView.addSubview(slot)
-
- let inspectorContainer = NSView(frame: slot.bounds)
- inspectorContainer.autoresizingMask = [.width, .height]
- let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
- inspectorView.autoresizingMask = [.width, .height]
- inspectorContainer.addSubview(inspectorView)
- slot.addSubview(inspectorContainer)
- contentView.layoutSubtreeIfNeeded()
-
- XCTAssertTrue(
- window.makeFirstResponder(inspectorView),
- "Precondition failed: inspector probe should become first responder"
- )
- XCTAssertTrue(window.firstResponder === inspectorView)
-
- slot.isHidden = true
-
- XCTAssertFalse(
- window.firstResponder === inspectorView,
- "Hiding a browser slot should yield any owned inspector responder before it goes off-screen"
- )
- if let firstResponderView = window.firstResponder as? NSView {
- XCTAssertFalse(
- firstResponderView === slot || firstResponderView.isDescendant(of: slot),
- "Hiding a browser slot should not leave first responder inside the hidden slot"
- )
- }
- }
-
- func testHiddenPortalSyncDoesNotStealLocallyHostedDevToolsWebViewDuringResize() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 520, height: 320),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- realizeWindowLayout(window)
- let portal = WindowBrowserPortal(window: window)
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 260, height: 180))
- contentView.addSubview(anchor)
-
- let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
- portal.bind(webView: webView, to: anchor, visibleInUI: true)
- contentView.layoutSubtreeIfNeeded()
- portal.synchronizeWebViewForAnchor(anchor)
- advanceAnimations()
-
- guard let hiddenPortalSlot = webView.superview as? WindowBrowserSlotView else {
- XCTFail("Expected browser slot")
- return
- }
-
- portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0)
- portal.synchronizeWebViewForAnchor(anchor)
- advanceAnimations()
- XCTAssertTrue(hiddenPortalSlot.isHidden, "Hidden portal entry should keep its slot hidden")
-
- let localInlineSlot = WindowBrowserSlotView(frame: anchor.frame)
- contentView.addSubview(localInlineSlot)
-
- let inspectorView = WKInspectorProbeView(
- frame: NSRect(x: 0, y: 0, width: localInlineSlot.bounds.width, height: 72)
- )
- inspectorView.autoresizingMask = [.width]
- localInlineSlot.addSubview(inspectorView)
-
- localInlineSlot.addSubview(webView)
- webView.frame = NSRect(
- x: 0,
- y: inspectorView.frame.maxY,
- width: localInlineSlot.bounds.width,
- height: localInlineSlot.bounds.height - inspectorView.frame.height
- )
- localInlineSlot.layoutSubtreeIfNeeded()
-
- anchor.frame = NSRect(x: 40, y: 24, width: 220, height: 180)
- localInlineSlot.frame = anchor.frame
- contentView.layoutSubtreeIfNeeded()
- localInlineSlot.layoutSubtreeIfNeeded()
- portal.synchronizeWebViewForAnchor(anchor)
-
- XCTAssertTrue(
- webView.superview === localInlineSlot,
- "Hidden portal sync should not steal a DevTools-hosted web view back out of local inline hosting during pane resize"
- )
- XCTAssertTrue(
- inspectorView.superview === localInlineSlot,
- "Hidden portal sync should leave local DevTools companion views in the local inline host"
- )
- XCTAssertTrue(hiddenPortalSlot.isHidden, "The retiring hidden portal slot should stay hidden during local inline hosting")
- }
-
- func testPortalHostBoundsBecomeReadyAfterBindingInFrameDrivenHierarchy() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- realizeWindowLayout(window)
- let portal = WindowBrowserPortal(window: window)
-
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
- let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 220, height: 160))
- contentView.addSubview(anchor)
-
- let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
- portal.bind(webView: webView, to: anchor, visibleInUI: true)
- portal.synchronizeWebViewForAnchor(anchor)
-
- guard let slot = webView.superview as? WindowBrowserSlotView,
- let host = slot.superview as? WindowBrowserHostView else {
- XCTFail("Expected portal slot + host views")
- return
- }
- XCTAssertGreaterThan(host.bounds.width, 1, "Portal host width should be ready for clipping/sync")
- XCTAssertGreaterThan(host.bounds.height, 1, "Portal host height should be ready for clipping/sync")
- }
-
- func testPortalDropZoneOverlayPersistsAcrossVisibilityChanges() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- realizeWindowLayout(window)
- let portal = WindowBrowserPortal(window: window)
-
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
- let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 220, height: 160))
- contentView.addSubview(anchor)
-
- let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
- portal.bind(webView: webView, to: anchor, visibleInUI: true)
- portal.synchronizeWebViewForAnchor(anchor)
-
- guard let slot = webView.superview as? WindowBrowserSlotView,
- let overlay = dropZoneOverlay(in: slot, excluding: webView) else {
- XCTFail("Expected browser slot overlay")
- return
- }
-
- XCTAssertTrue(overlay.isHidden, "Overlay should start hidden without an active drop zone")
-
- portal.updateDropZoneOverlay(forWebViewId: ObjectIdentifier(webView), zone: .right)
- slot.layoutSubtreeIfNeeded()
- XCTAssertFalse(overlay.isHidden)
- XCTAssertTrue(slot.superview?.subviews.last === overlay, "Overlay should remain above the hosted web view")
- XCTAssertEqual(overlay.frame.origin.x, slot.frame.origin.x + 110, accuracy: 0.5)
- XCTAssertEqual(overlay.frame.origin.y, slot.frame.origin.y + 4, accuracy: 0.5)
- XCTAssertEqual(overlay.frame.size.width, 106, accuracy: 0.5)
- XCTAssertEqual(overlay.frame.size.height, 152, accuracy: 0.5)
-
- portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0)
- portal.synchronizeWebViewForAnchor(anchor)
- advanceAnimations()
- XCTAssertTrue(overlay.isHidden, "Invisible browser entries should hide the overlay")
-
- portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: true, zPriority: 0)
- portal.synchronizeWebViewForAnchor(anchor)
- XCTAssertFalse(overlay.isHidden, "Restoring visibility should restore the active drop-zone overlay")
- }
-
- func testPortalRevealRefreshesHostedWebViewWithoutFrameDelta() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- realizeWindowLayout(window)
- let portal = WindowBrowserPortal(window: window)
-
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
- let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 220, height: 160))
- contentView.addSubview(anchor)
-
- let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration())
- portal.bind(webView: webView, to: anchor, visibleInUI: true)
- portal.synchronizeWebViewForAnchor(anchor)
- advanceAnimations()
- let initialDisplayCount = webView.displayIfNeededCount
- let initialReattachCount = webView.reattachRenderingStateCount
-
- portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0)
- portal.synchronizeWebViewForAnchor(anchor)
- advanceAnimations()
- let hiddenDisplayCount = webView.displayIfNeededCount
- let hiddenReattachCount = webView.reattachRenderingStateCount
-
- portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: true, zPriority: 0)
- portal.synchronizeWebViewForAnchor(anchor)
- advanceAnimations()
-
- XCTAssertGreaterThanOrEqual(hiddenDisplayCount, initialDisplayCount)
- XCTAssertEqual(
- hiddenReattachCount,
- initialReattachCount,
- "Hiding a portal-hosted browser should not itself trigger the WebKit reattach path"
- )
- XCTAssertGreaterThan(
- webView.displayIfNeededCount,
- hiddenDisplayCount,
- "Revealing an existing portal-hosted browser should refresh WebKit presentation immediately"
- )
- XCTAssertGreaterThan(
- webView.reattachRenderingStateCount,
- hiddenReattachCount,
- "Revealing an existing portal-hosted browser should trigger the WebKit reattach path"
- )
- }
-
- func testVisiblePortalEntryHidesWithoutDetachingDuringTransientAnchorRemovalUntilRebind() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- realizeWindowLayout(window)
- let portal = WindowBrowserPortal(window: window)
-
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let anchorFrame = NSRect(x: 40, y: 24, width: 220, height: 160)
- let anchor1 = NSView(frame: anchorFrame)
- contentView.addSubview(anchor1)
-
- let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration())
- portal.bind(webView: webView, to: anchor1, visibleInUI: true)
- portal.synchronizeWebViewForAnchor(anchor1)
- advanceAnimations()
-
- guard let slot = webView.superview as? WindowBrowserSlotView else {
- XCTFail("Expected browser slot")
- return
- }
-
- anchor1.removeFromSuperview()
- portal.synchronizeWebViewForAnchor(anchor1)
- advanceAnimations()
-
- XCTAssertTrue(webView.superview === slot, "Visible browser entries should not detach during transient anchor removal")
- XCTAssertTrue(
- slot.isHidden,
- "Transient anchor churn should hide the stale browser slot instead of rendering in the wrong pane"
- )
- XCTAssertEqual(portal.debugEntryCount(), 1)
-
- let displayCountBeforeRebind = webView.displayIfNeededCount
- let anchor2 = NSView(frame: anchorFrame)
- contentView.addSubview(anchor2)
- portal.bind(webView: webView, to: anchor2, visibleInUI: true)
- portal.synchronizeWebViewForAnchor(anchor2)
- advanceAnimations()
-
- XCTAssertTrue(webView.superview === slot, "Rebinding after transient anchor removal should reuse the existing portal slot")
- XCTAssertFalse(slot.isHidden)
- XCTAssertEqual(portal.debugEntryCount(), 1)
- XCTAssertGreaterThan(
- webView.displayIfNeededCount,
- displayCountBeforeRebind,
- "Anchor rebinds should refresh hosted browser presentation even when geometry is unchanged"
- )
- }
-
- func testVisiblePortalEntryStaysVisibleDuringOffWindowAnchorReparentUntilRebind() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- realizeWindowLayout(window)
- let portal = WindowBrowserPortal(window: window)
-
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let anchorFrame = NSRect(x: 40, y: 24, width: 220, height: 160)
- let anchor = NSView(frame: anchorFrame)
- contentView.addSubview(anchor)
-
- let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration())
- portal.bind(webView: webView, to: anchor, visibleInUI: true)
- portal.synchronizeWebViewForAnchor(anchor)
- advanceAnimations()
-
- guard let slot = webView.superview as? WindowBrowserSlotView else {
- XCTFail("Expected browser slot")
- return
- }
-
- let offWindowContainer = NSView(frame: anchorFrame)
- anchor.removeFromSuperview()
- offWindowContainer.addSubview(anchor)
- portal.synchronizeWebViewForAnchor(anchor)
- advanceAnimations()
-
- XCTAssertTrue(
- webView.superview === slot,
- "Off-window anchor reparent should preserve the hosted browser slot during drag churn"
- )
- XCTAssertFalse(
- slot.isHidden,
- "Off-window anchor reparent should keep the visible browser portal alive until the anchor returns"
- )
- XCTAssertEqual(portal.debugEntryCount(), 1)
-
- contentView.addSubview(anchor)
- portal.synchronizeWebViewForAnchor(anchor)
- advanceAnimations()
-
- XCTAssertTrue(webView.superview === slot, "Rebinding after off-window reparent should reuse the existing portal slot")
- XCTAssertFalse(slot.isHidden)
- XCTAssertEqual(portal.debugEntryCount(), 1)
- }
-
- func testRegistryDetachRemovesPortalHostedWebView() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- realizeWindowLayout(window)
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let anchor = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 120))
- contentView.addSubview(anchor)
- let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
-
- BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true)
- XCTAssertNotNil(webView.superview)
-
- BrowserWindowPortalRegistry.detach(webView: webView)
- XCTAssertNil(webView.superview)
- }
-
- func testRegistryHideKeepsPortalHostedWebViewAttachedButHidden() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- realizeWindowLayout(window)
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let anchor = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 120))
- contentView.addSubview(anchor)
- let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
-
- BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true)
- BrowserWindowPortalRegistry.synchronizeForAnchor(anchor)
- advanceAnimations()
-
- guard let slot = webView.superview as? WindowBrowserSlotView else {
- XCTFail("Expected browser slot")
- return
- }
- XCTAssertFalse(slot.isHidden)
-
- BrowserWindowPortalRegistry.hide(webView: webView, source: "unitTest")
- advanceAnimations()
-
- XCTAssertTrue(webView.superview === slot, "Hiding should preserve the hosted WKWebView attachment")
- XCTAssertTrue(slot.isHidden, "Hiding should immediately hide the existing portal slot")
- }
-
- func testHiddenPortalEntrySurvivesAnchorRemovalUntilWorkspaceRebind() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer { window.orderOut(nil) }
- realizeWindowLayout(window)
- let portal = WindowBrowserPortal(window: window)
-
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let anchorFrame = NSRect(x: 40, y: 24, width: 220, height: 160)
- let oldAnchor = NSView(frame: anchorFrame)
- contentView.addSubview(oldAnchor)
-
- let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration())
- portal.bind(webView: webView, to: oldAnchor, visibleInUI: true)
- portal.synchronizeWebViewForAnchor(oldAnchor)
- advanceAnimations()
-
- guard let slot = webView.superview as? WindowBrowserSlotView else {
- XCTFail("Expected browser slot")
- return
- }
-
- portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0)
- portal.synchronizeWebViewForAnchor(oldAnchor)
- advanceAnimations()
- XCTAssertTrue(slot.isHidden, "Workspace handoff should hide the retiring browser before unmount")
-
- oldAnchor.removeFromSuperview()
- portal.synchronizeWebViewForAnchor(oldAnchor)
- advanceAnimations()
-
- XCTAssertTrue(
- webView.superview === slot,
- "Hidden workspace browsers should stay attached while their SwiftUI anchor is temporarily unmounted"
- )
- XCTAssertTrue(slot.isHidden, "Unmounted hidden workspace browser should remain hidden until rebound")
- XCTAssertEqual(portal.debugEntryCount(), 1, "Workspace handoff should keep the hidden browser portal entry alive")
-
- let displayCountBeforeRebind = webView.displayIfNeededCount
- let newAnchor = NSView(frame: anchorFrame)
- contentView.addSubview(newAnchor)
- portal.bind(webView: webView, to: newAnchor, visibleInUI: true)
- portal.synchronizeWebViewForAnchor(newAnchor)
- advanceAnimations()
-
- XCTAssertTrue(
- webView.superview === slot,
- "Selecting the workspace again should reuse the existing hidden browser portal slot"
- )
- XCTAssertFalse(slot.isHidden, "Rebinding the workspace browser should reveal the existing portal slot")
- XCTAssertEqual(portal.debugEntryCount(), 1)
- XCTAssertGreaterThan(
- webView.displayIfNeededCount,
- displayCountBeforeRebind,
- "Workspace rebind should refresh the preserved browser without recreating its portal slot"
- )
- }
-}
-
-@MainActor
-final class FileDropOverlayViewTests: XCTestCase {
- private func realizeWindowLayout(_ window: NSWindow) {
- window.makeKeyAndOrderFront(nil)
- window.displayIfNeeded()
- window.contentView?.layoutSubtreeIfNeeded()
- RunLoop.current.run(until: Date().addingTimeInterval(0.05))
- window.contentView?.layoutSubtreeIfNeeded()
- }
-
- func testOverlayResolvesPortalHostedBrowserWebViewForFileDrops() {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 420, height: 280),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- defer {
- NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window)
- window.orderOut(nil)
- }
- realizeWindowLayout(window)
-
- guard let contentView = window.contentView,
- let container = contentView.superview else {
- XCTFail("Expected content container")
- return
- }
-
- let anchor = NSView(frame: NSRect(x: 40, y: 36, width: 220, height: 150))
- contentView.addSubview(anchor)
-
- let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
- BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true)
- BrowserWindowPortalRegistry.synchronizeForAnchor(anchor)
-
- let overlay = FileDropOverlayView(frame: container.bounds)
- overlay.autoresizingMask = [.width, .height]
- container.addSubview(overlay, positioned: .above, relativeTo: nil)
-
- let point = anchor.convert(
- NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY),
- to: nil
- )
- XCTAssertTrue(
- overlay.webViewUnderPoint(point) === webView,
- "File-drop overlay should resolve portal-hosted browser panes so Finder uploads still reach WKWebView"
- )
- }
-}
-
-@MainActor
-final class MarkdownPanelPointerObserverViewTests: XCTestCase {
- private func makeWindow() -> NSWindow {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 320, height: 180),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- window.makeKeyAndOrderFront(nil)
- window.displayIfNeeded()
- window.contentView?.layoutSubtreeIfNeeded()
- return window
- }
-
- private func makeMouseEvent(
- type: NSEvent.EventType,
- location: NSPoint,
- window: NSWindow,
- eventNumber: Int = 1
- ) -> NSEvent {
- guard let event = NSEvent.mouseEvent(
- with: type,
- location: location,
- modifierFlags: [],
- timestamp: ProcessInfo.processInfo.systemUptime,
- windowNumber: window.windowNumber,
- context: nil,
- eventNumber: eventNumber,
- clickCount: 1,
- pressure: 1.0
- ) else {
- fatalError("Expected to create mouse event")
- }
- return event
- }
-
- func testObserverTriggersFocusForVisibleLeftClickInsideBounds() {
- let window = makeWindow()
- defer { window.orderOut(nil) }
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let overlay = MarkdownPanelPointerObserverView(frame: contentView.bounds)
- overlay.autoresizingMask = [.width, .height]
- let focusExpectation = expectation(description: "observer forwards focus callback")
- var pointerDownCount = 0
- overlay.onPointerDown = {
- pointerDownCount += 1
- focusExpectation.fulfill()
- }
- contentView.addSubview(overlay)
-
- _ = overlay.handleEventIfNeeded(
- makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 60, y: 60), window: window)
- )
- wait(for: [focusExpectation], timeout: 1.0)
-
- XCTAssertEqual(pointerDownCount, 1)
- }
-
- func testObserverIgnoresOutsideOrForeignWindowClicks() {
- let window = makeWindow()
- defer { window.orderOut(nil) }
- let otherWindow = makeWindow()
- defer { otherWindow.orderOut(nil) }
- guard let contentView = window.contentView else {
- XCTFail("Expected content view")
- return
- }
-
- let overlay = MarkdownPanelPointerObserverView(frame: contentView.bounds)
- overlay.autoresizingMask = [.width, .height]
- let noFocusExpectation = expectation(description: "observer ignores invalid clicks")
- noFocusExpectation.isInverted = true
- var pointerDownCount = 0
- overlay.onPointerDown = {
- pointerDownCount += 1
- noFocusExpectation.fulfill()
- }
- contentView.addSubview(overlay)
-
- _ = overlay.handleEventIfNeeded(
- makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 400, y: 400), window: window)
- )
- _ = overlay.handleEventIfNeeded(
- makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 60, y: 60), window: otherWindow, eventNumber: 2)
- )
- _ = overlay.handleEventIfNeeded(
- makeMouseEvent(type: .leftMouseDragged, location: NSPoint(x: 60, y: 60), window: window, eventNumber: 3)
- )
- wait(for: [noFocusExpectation], timeout: 0.1)
-
- XCTAssertEqual(pointerDownCount, 0)
- }
-
- func testObserverDoesNotParticipateInHitTesting() {
- let overlay = MarkdownPanelPointerObserverView(frame: NSRect(x: 0, y: 0, width: 200, height: 100))
- XCTAssertNil(overlay.hitTest(NSPoint(x: 40, y: 30)))
- }
-}
-
-final class BrowserLinkOpenSettingsTests: XCTestCase {
- private var suiteName: String!
- private var defaults: UserDefaults!
-
- override func setUp() {
- super.setUp()
- suiteName = "BrowserLinkOpenSettingsTests.\(UUID().uuidString)"
- defaults = UserDefaults(suiteName: suiteName)
- defaults.removePersistentDomain(forName: suiteName)
- }
-
- override func tearDown() {
- defaults.removePersistentDomain(forName: suiteName)
- defaults = nil
- suiteName = nil
- super.tearDown()
- }
-
- func testTerminalLinksDefaultToCmuxBrowser() {
- XCTAssertTrue(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser(defaults: defaults))
- }
-
- func testTerminalLinksPreferenceUsesStoredValue() {
- defaults.set(false, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey)
- XCTAssertFalse(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser(defaults: defaults))
-
- defaults.set(true, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey)
- XCTAssertTrue(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser(defaults: defaults))
- }
-
- func testSidebarPullRequestLinksDefaultToCmuxBrowser() {
- XCTAssertTrue(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowser(defaults: defaults))
- }
-
- func testSidebarPullRequestLinksPreferenceUsesStoredValue() {
- defaults.set(false, forKey: BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey)
- XCTAssertFalse(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowser(defaults: defaults))
-
- defaults.set(true, forKey: BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey)
- XCTAssertTrue(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowser(defaults: defaults))
- }
-
- func testOpenCommandInterceptionDefaultsToCmuxBrowser() {
- XCTAssertTrue(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults))
- }
-
- func testOpenCommandInterceptionUsesStoredValue() {
- defaults.set(false, forKey: BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey)
- XCTAssertFalse(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults))
-
- defaults.set(true, forKey: BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey)
- XCTAssertTrue(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults))
- }
-
- func testOpenCommandInterceptionFallsBackToLegacyLinkToggleWhenUnset() {
- defaults.set(false, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey)
- XCTAssertFalse(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults))
-
- defaults.set(true, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey)
- XCTAssertTrue(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults))
- }
-
- func testSettingsInitialOpenCommandInterceptionValueFallsBackToLegacyLinkToggleWhenUnset() {
- defaults.set(false, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey)
- XCTAssertFalse(BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue(defaults: defaults))
-
- defaults.set(true, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey)
- XCTAssertTrue(BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue(defaults: defaults))
- }
-
- func testExternalOpenPatternsDefaultToEmpty() {
- XCTAssertTrue(BrowserLinkOpenSettings.externalOpenPatterns(defaults: defaults).isEmpty)
- }
-
- func testExternalOpenLiteralPatternMatchesCaseInsensitively() {
- defaults.set("openai.com/account/usage", forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey)
- XCTAssertTrue(
- BrowserLinkOpenSettings.shouldOpenExternally(
- "https://platform.OPENAI.com/account/usage",
- defaults: defaults
- )
- )
- }
-
- func testExternalOpenRegexPatternMatchesCaseInsensitively() {
- defaults.set(
- "re:^https?://[^/]*\\.example\\.com/(billing|usage)",
- forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey
- )
- XCTAssertTrue(
- BrowserLinkOpenSettings.shouldOpenExternally(
- "https://FOO.example.com/BILLING",
- defaults: defaults
- )
- )
- }
-
- func testExternalOpenRegexPatternSupportsDigitCharacterClass() {
- defaults.set(
- "re:^https://example\\.com/usage/\\d+$",
- forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey
- )
- XCTAssertTrue(
- BrowserLinkOpenSettings.shouldOpenExternally(
- "https://example.com/usage/42",
- defaults: defaults
- )
- )
- }
-
- func testExternalOpenPatternsIgnoreInvalidRegexEntries() {
- defaults.set("re:(\nexample.com", forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey)
- XCTAssertTrue(
- BrowserLinkOpenSettings.shouldOpenExternally(
- "https://example.com/path",
- defaults: defaults
- )
- )
- }
-}
-
-final class TerminalOpenURLTargetResolutionTests: XCTestCase {
- func testResolvesHTTPSAsEmbeddedBrowser() throws {
- let target = try XCTUnwrap(resolveTerminalOpenURLTarget("https://example.com/path?q=1"))
- switch target {
- case let .embeddedBrowser(url):
- XCTAssertEqual(url.scheme, "https")
- XCTAssertEqual(url.host, "example.com")
- XCTAssertEqual(url.path, "/path")
- default:
- XCTFail("Expected web URL to route to embedded browser")
- }
- }
-
- func testResolvesBareDomainAsEmbeddedBrowser() throws {
- let target = try XCTUnwrap(resolveTerminalOpenURLTarget("example.com/docs"))
- switch target {
- case let .embeddedBrowser(url):
- XCTAssertEqual(url.scheme, "https")
- XCTAssertEqual(url.host, "example.com")
- XCTAssertEqual(url.path, "/docs")
- default:
- XCTFail("Expected bare domain to be normalized as an HTTPS browser URL")
- }
- }
-
- func testResolvesFileSchemeAsExternal() throws {
- let target = try XCTUnwrap(resolveTerminalOpenURLTarget("file:///tmp/cmux.txt"))
- switch target {
- case let .external(url):
- XCTAssertTrue(url.isFileURL)
- XCTAssertEqual(url.path, "/tmp/cmux.txt")
- default:
- XCTFail("Expected file URL to open externally")
- }
- }
-
- func testResolvesAbsolutePathAsExternalFileURL() throws {
- let target = try XCTUnwrap(resolveTerminalOpenURLTarget("/tmp/cmux-path.txt"))
- switch target {
- case let .external(url):
- XCTAssertTrue(url.isFileURL)
- XCTAssertEqual(url.path, "/tmp/cmux-path.txt")
- default:
- XCTFail("Expected absolute file path to open externally")
- }
- }
-
- func testResolvesNonWebSchemeAsExternal() throws {
- let target = try XCTUnwrap(resolveTerminalOpenURLTarget("mailto:test@example.com"))
- switch target {
- case let .external(url):
- XCTAssertEqual(url.scheme, "mailto")
- default:
- XCTFail("Expected non-web scheme to open externally")
- }
- }
-
- func testResolvesHostlessHTTPSAsExternal() throws {
- let target = try XCTUnwrap(resolveTerminalOpenURLTarget("https:///tmp/cmux.txt"))
- switch target {
- case let .external(url):
- XCTAssertEqual(url.scheme, "https")
- XCTAssertNil(url.host)
- XCTAssertEqual(url.path, "/tmp/cmux.txt")
- default:
- XCTFail("Expected hostless HTTPS URL to open externally")
- }
- }
-}
-
-final class BrowserNavigableURLResolutionTests: XCTestCase {
- func testResolvesFileSchemeAsNavigableURL() throws {
- let resolved = try XCTUnwrap(resolveBrowserNavigableURL("file:///tmp/cmux-local-test.html"))
- XCTAssertTrue(resolved.isFileURL)
- XCTAssertEqual(resolved.path, "/tmp/cmux-local-test.html")
- }
-
- func testRejectsNonWebNonFileScheme() {
- XCTAssertNil(resolveBrowserNavigableURL("mailto:test@example.com"))
- XCTAssertNil(resolveBrowserNavigableURL("ftp://example.com/file.html"))
- }
-
- func testRejectsHostOnlyFileURL() {
- XCTAssertNil(resolveBrowserNavigableURL("file://example.html"))
- }
-}
-
-final class BrowserReadAccessURLTests: XCTestCase {
- func testUsesParentDirectoryForFileURL() throws {
- let tempRoot = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
- let dir = tempRoot.appendingPathComponent("BrowserReadAccessURLTests-\(UUID().uuidString)", isDirectory: true)
- let file = dir.appendingPathComponent("sample.html")
- try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
- defer { try? FileManager.default.removeItem(at: dir) }
- try "".write(to: file, atomically: true, encoding: .utf8)
-
- let readAccessURL = try XCTUnwrap(browserReadAccessURL(forLocalFileURL: file))
- XCTAssertEqual(readAccessURL.standardizedFileURL, dir.standardizedFileURL)
- }
-
- func testUsesDirectoryURLWhenTargetIsDirectory() throws {
- let tempRoot = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
- let dir = tempRoot.appendingPathComponent("BrowserReadAccessURLTests-\(UUID().uuidString)", isDirectory: true)
- try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
- defer { try? FileManager.default.removeItem(at: dir) }
-
- let readAccessURL = try XCTUnwrap(browserReadAccessURL(forLocalFileURL: dir))
- XCTAssertEqual(readAccessURL.standardizedFileURL, dir.standardizedFileURL)
- }
-
- func testUsesParentDirectoryWhenFileDoesNotExist() throws {
- let missing = URL(fileURLWithPath: "/tmp/\(UUID().uuidString).html")
- let readAccessURL = try XCTUnwrap(browserReadAccessURL(forLocalFileURL: missing))
- XCTAssertEqual(readAccessURL.standardizedFileURL, missing.deletingLastPathComponent().standardizedFileURL)
- }
-
- func testReturnsNilForHostOnlyFileURL() throws {
- let hostOnly = try XCTUnwrap(URL(string: "file://example.html"))
- XCTAssertNil(browserReadAccessURL(forLocalFileURL: hostOnly))
- }
-}
-
-final class BrowserExternalNavigationSchemeTests: XCTestCase {
- func testCustomAppSchemesOpenExternally() throws {
- let discord = try XCTUnwrap(URL(string: "discord://login/one-time?token=abc"))
- let slack = try XCTUnwrap(URL(string: "slack://open"))
- let zoom = try XCTUnwrap(URL(string: "zoommtg://zoom.us/join"))
- let mailto = try XCTUnwrap(URL(string: "mailto:test@example.com"))
-
- XCTAssertTrue(browserShouldOpenURLExternally(discord))
- XCTAssertTrue(browserShouldOpenURLExternally(slack))
- XCTAssertTrue(browserShouldOpenURLExternally(zoom))
- XCTAssertTrue(browserShouldOpenURLExternally(mailto))
- }
-
- func testEmbeddedBrowserSchemesStayInWebView() throws {
- let https = try XCTUnwrap(URL(string: "https://example.com"))
- let http = try XCTUnwrap(URL(string: "http://example.com"))
- let about = try XCTUnwrap(URL(string: "about:blank"))
- let data = try XCTUnwrap(URL(string: "data:text/plain,hello"))
- let file = try XCTUnwrap(URL(string: "file:///tmp/cmux-local-test.html"))
- let blob = try XCTUnwrap(URL(string: "blob:https://example.com/550e8400-e29b-41d4-a716-446655440000"))
- let javascript = try XCTUnwrap(URL(string: "javascript:void(0)"))
- let webkitInternal = try XCTUnwrap(URL(string: "applewebdata://local/page"))
-
- XCTAssertFalse(browserShouldOpenURLExternally(https))
- XCTAssertFalse(browserShouldOpenURLExternally(http))
- XCTAssertFalse(browserShouldOpenURLExternally(about))
- XCTAssertFalse(browserShouldOpenURLExternally(data))
- XCTAssertFalse(browserShouldOpenURLExternally(file))
- XCTAssertFalse(browserShouldOpenURLExternally(blob))
- XCTAssertFalse(browserShouldOpenURLExternally(javascript))
- XCTAssertFalse(browserShouldOpenURLExternally(webkitInternal))
- }
-}
-
-final class BrowserHostWhitelistTests: XCTestCase {
- private var suiteName: String!
- private var defaults: UserDefaults!
-
- override func setUp() {
- super.setUp()
- suiteName = "BrowserHostWhitelistTests.\(UUID().uuidString)"
- defaults = UserDefaults(suiteName: suiteName)
- defaults.removePersistentDomain(forName: suiteName)
- }
-
- override func tearDown() {
- defaults.removePersistentDomain(forName: suiteName)
- defaults = nil
- suiteName = nil
- super.tearDown()
- }
-
- func testEmptyWhitelistAllowsAll() {
- XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults))
- XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults))
- }
-
- func testExactMatch() {
- defaults.set("localhost\n127.0.0.1", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
- XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults))
- XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("127.0.0.1", defaults: defaults))
- XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults))
- }
-
- func testExactMatchIsCaseInsensitive() {
- defaults.set("LocalHost", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
- XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults))
- XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("LOCALHOST", defaults: defaults))
- }
-
- func testWildcardSuffix() {
- defaults.set("*.localtest.me", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
- XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("app.localtest.me", defaults: defaults))
- XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("sub.app.localtest.me", defaults: defaults))
- XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localtest.me", defaults: defaults))
- XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults))
- }
-
- func testWildcardIsCaseInsensitive() {
- defaults.set("*.Example.COM", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
- XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("sub.example.com", defaults: defaults))
- }
-
- func testBlankLinesAndWhitespaceIgnored() {
- defaults.set(" localhost \n\n 127.0.0.1 \n", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
- XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults))
- XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("127.0.0.1", defaults: defaults))
- XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults))
- }
-
- func testMixedExactAndWildcard() {
- defaults.set("localhost\n127.0.0.1\n*.local.dev", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
- XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults))
- XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("127.0.0.1", defaults: defaults))
- XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("app.local.dev", defaults: defaults))
- XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("github.com", defaults: defaults))
- }
-
- func testDefaultWhitelistIsEmpty() {
- let patterns = BrowserLinkOpenSettings.hostWhitelist(defaults: defaults)
- XCTAssertTrue(patterns.isEmpty)
- }
-
- func testWildcardRequiresDotBoundary() {
- defaults.set("*.example.com", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
- XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("badexample.com", defaults: defaults))
- XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com.evil", defaults: defaults))
- }
-
- func testWhitelistNormalizesSchemesPortsAndTrailingDots() {
- defaults.set("https://LOCALHOST:3000/path\n*.Example.COM:443", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
- XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost.", defaults: defaults))
- XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("api.example.com", defaults: defaults))
- }
-
- func testInvalidWhitelistEntriesDoNotImplicitlyAllowAll() {
- defaults.set("http://\n*.\n", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
- XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults))
- }
-
- func testUnicodeWhitelistEntryMatchesPunycodeHost() {
- defaults.set("b\u{00FC}cher.example", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
- XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("xn--bcher-kva.example", defaults: defaults))
- }
-}
-
-final class TerminalControllerSidebarDedupeTests: XCTestCase {
- func testShouldReplaceStatusEntryReturnsFalseForUnchangedPayload() {
- let current = SidebarStatusEntry(
- key: "agent",
- value: "idle",
- icon: "bolt",
- color: "#ffffff",
- timestamp: Date(timeIntervalSince1970: 123)
- )
- XCTAssertFalse(
- TerminalController.shouldReplaceStatusEntry(
- current: current,
- key: "agent",
- value: "idle",
- icon: "bolt",
- color: "#ffffff",
- url: nil,
- priority: 0,
- format: .plain
- )
- )
- }
-
- func testShouldReplaceStatusEntryReturnsTrueWhenValueChanges() {
- let current = SidebarStatusEntry(
- key: "agent",
- value: "idle",
- icon: "bolt",
- color: "#ffffff",
- timestamp: Date(timeIntervalSince1970: 123)
- )
- XCTAssertTrue(
- TerminalController.shouldReplaceStatusEntry(
- current: current,
- key: "agent",
- value: "running",
- icon: "bolt",
- color: "#ffffff",
- url: nil,
- priority: 0,
- format: .plain
- )
- )
- }
-
- func testShouldReplaceProgressReturnsFalseForUnchangedPayload() {
- XCTAssertFalse(
- TerminalController.shouldReplaceProgress(
- current: SidebarProgressState(value: 0.42, label: "indexing"),
- value: 0.42,
- label: "indexing"
- )
- )
- }
-
- func testShouldReplaceGitBranchReturnsFalseForUnchangedPayload() {
- XCTAssertFalse(
- TerminalController.shouldReplaceGitBranch(
- current: SidebarGitBranchState(branch: "main", isDirty: true),
- branch: "main",
- isDirty: true
- )
- )
- }
-
- func testShouldReplacePortsIgnoresOrderAndDuplicates() {
- XCTAssertFalse(
- TerminalController.shouldReplacePorts(
- current: [9229, 3000],
- next: [3000, 9229, 3000]
- )
- )
- XCTAssertTrue(
- TerminalController.shouldReplacePorts(
- current: [9229, 3000],
- next: [3000]
- )
- )
- }
-
- func testExplicitSocketScopeParsesValidUUIDTabAndPanel() {
- let workspaceId = UUID()
- let panelId = UUID()
- let scope = TerminalController.explicitSocketScope(
- options: [
- "tab": workspaceId.uuidString,
- "panel": panelId.uuidString
- ]
- )
- XCTAssertEqual(scope?.workspaceId, workspaceId)
- XCTAssertEqual(scope?.panelId, panelId)
- }
-
- func testExplicitSocketScopeAcceptsSurfaceAlias() {
- let workspaceId = UUID()
- let panelId = UUID()
- let scope = TerminalController.explicitSocketScope(
- options: [
- "tab": workspaceId.uuidString,
- "surface": panelId.uuidString
- ]
- )
- XCTAssertEqual(scope?.workspaceId, workspaceId)
- XCTAssertEqual(scope?.panelId, panelId)
- }
-
- func testExplicitSocketScopeRejectsMissingOrInvalidValues() {
- XCTAssertNil(TerminalController.explicitSocketScope(options: [:]))
- XCTAssertNil(TerminalController.explicitSocketScope(options: ["tab": "workspace:1", "panel": UUID().uuidString]))
- XCTAssertNil(TerminalController.explicitSocketScope(options: ["tab": UUID().uuidString, "panel": "surface:1"]))
- }
-
- func testNormalizeReportedDirectoryTrimsWhitespace() {
- XCTAssertEqual(
- TerminalController.normalizeReportedDirectory(" /Users/cmux/project "),
- "/Users/cmux/project"
- )
- }
-
- func testNormalizeReportedDirectoryResolvesFileURL() {
- XCTAssertEqual(
- TerminalController.normalizeReportedDirectory("file:///Users/cmux/project"),
- "/Users/cmux/project"
- )
- }
-
- func testNormalizeReportedDirectoryLeavesInvalidURLTrimmed() {
- XCTAssertEqual(
- TerminalController.normalizeReportedDirectory(" file://bad host "),
- "file://bad host"
- )
- }
-}
-
-final class TerminalControllerSocketTextChunkTests: XCTestCase {
- func testSocketTextChunksReturnsSingleChunkForPlainText() {
- XCTAssertEqual(
- TerminalController.socketTextChunks("echo hello"),
- [.text("echo hello")]
- )
- }
-
- func testSocketTextChunksSplitsControlScalars() {
- XCTAssertEqual(
- TerminalController.socketTextChunks("abc\rdef\tghi"),
- [
- .text("abc"),
- .control("\r".unicodeScalars.first!),
- .text("def"),
- .control("\t".unicodeScalars.first!),
- .text("ghi")
- ]
- )
- }
-
- func testSocketTextChunksDoesNotEmitEmptyTextChunksAroundConsecutiveControls() {
- XCTAssertEqual(
- TerminalController.socketTextChunks("\r\n\t"),
- [
- .control("\r".unicodeScalars.first!),
- .control("\n".unicodeScalars.first!),
- .control("\t".unicodeScalars.first!)
- ]
- )
- }
-}
-
-final class BrowserOmnibarFocusPolicyTests: XCTestCase {
- func testReacquiresFocusWhenOmnibarStillWantsFocusAndNextResponderIsNotAnotherTextField() {
- XCTAssertTrue(
- browserOmnibarShouldReacquireFocusAfterEndEditing(
- desiredOmnibarFocus: true,
- nextResponderIsOtherTextField: false
- )
- )
- }
-
- func testDoesNotReacquireFocusWhenAnotherTextFieldAlreadyTookFocus() {
- XCTAssertFalse(
- browserOmnibarShouldReacquireFocusAfterEndEditing(
- desiredOmnibarFocus: true,
- nextResponderIsOtherTextField: true
- )
- )
- }
-
- func testDoesNotReacquireFocusWhenOmnibarNoLongerWantsFocus() {
- XCTAssertFalse(
- browserOmnibarShouldReacquireFocusAfterEndEditing(
- desiredOmnibarFocus: false,
- nextResponderIsOtherTextField: false
- )
- )
- }
-}
-
-final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase {
- func testImmediateStateUpdateAllowedWhenHostNotInWindow() {
- XCTAssertTrue(
- GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate(
- hostedViewHasSuperview: true,
- isBoundToCurrentHost: false
- )
- )
- }
-
- func testImmediateStateUpdateAllowedWhenBoundToCurrentHost() {
- XCTAssertTrue(
- GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate(
- hostedViewHasSuperview: true,
- isBoundToCurrentHost: true
- )
- )
- }
-
- func testImmediateStateUpdateSkippedForStaleHostBoundElsewhere() {
- XCTAssertFalse(
- GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate(
- hostedViewHasSuperview: true,
- isBoundToCurrentHost: false
- )
- )
- }
-
- func testImmediateStateUpdateAllowedWhenUnboundAndNotAttachedAnywhere() {
- XCTAssertTrue(
- GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate(
- hostedViewHasSuperview: false,
- isBoundToCurrentHost: false
- )
- )
- }
-}
-
-final class TerminalControllerSocketListenerHealthTests: XCTestCase {
- func testStableSocketBindPermissionFailureFallsBackToUserScopedSocket() {
- XCTAssertEqual(
- TerminalController.fallbackSocketPathAfterBindFailure(
- requestedPath: SocketControlSettings.stableDefaultSocketPath,
- stage: "bind",
- errnoCode: EACCES,
- currentUserID: 501
- ),
- SocketControlSettings.userScopedStableSocketPath(currentUserID: 501)
- )
- }
-
- func testNonStableSocketBindFailureDoesNotFallback() {
- XCTAssertNil(
- TerminalController.fallbackSocketPathAfterBindFailure(
- requestedPath: "/tmp/cmux-debug.sock",
- stage: "bind",
- errnoCode: EACCES,
- currentUserID: 501
- )
- )
- }
-
- private func makeTempSocketPath() -> String {
- "/tmp/cmux-socket-health-\(UUID().uuidString).sock"
- }
-
- private func bindUnixSocket(at path: String) throws -> Int32 {
- unlink(path)
-
- let fd = socket(AF_UNIX, SOCK_STREAM, 0)
- guard fd >= 0 else {
- throw NSError(
- domain: NSPOSIXErrorDomain,
- code: Int(errno),
- userInfo: [NSLocalizedDescriptionKey: "Failed to create Unix socket"]
- )
- }
-
- var addr = sockaddr_un()
- addr.sun_family = sa_family_t(AF_UNIX)
- path.withCString { ptr in
- withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in
- let pathBuf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self)
- strcpy(pathBuf, ptr)
- }
- }
-
- let bindResult = withUnsafePointer(to: &addr) { ptr in
- ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
- Darwin.bind(fd, sockaddrPtr, socklen_t(MemoryLayout.size))
- }
- }
- guard bindResult == 0 else {
- let code = Int(errno)
- Darwin.close(fd)
- throw NSError(
- domain: NSPOSIXErrorDomain,
- code: code,
- userInfo: [NSLocalizedDescriptionKey: "Failed to bind Unix socket"]
- )
- }
-
- guard Darwin.listen(fd, 1) == 0 else {
- let code = Int(errno)
- Darwin.close(fd)
- throw NSError(
- domain: NSPOSIXErrorDomain,
- code: code,
- userInfo: [NSLocalizedDescriptionKey: "Failed to listen on Unix socket"]
- )
- }
-
- return fd
- }
-
- private func acceptSingleClient(
- on listenerFD: Int32,
- handler: @escaping (_ clientFD: Int32) -> Void
- ) -> XCTestExpectation {
- let handled = expectation(description: "socket client handled")
- DispatchQueue.global(qos: .userInitiated).async {
- var clientAddr = sockaddr_un()
- var clientAddrLen = socklen_t(MemoryLayout.size)
- let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr in
- ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
- Darwin.accept(listenerFD, sockaddrPtr, &clientAddrLen)
- }
- }
- guard clientFD >= 0 else {
- handled.fulfill()
- return
- }
- defer {
- Darwin.close(clientFD)
- handled.fulfill()
- }
- handler(clientFD)
- }
- return handled
- }
-
- @MainActor
- func testSocketListenerHealthRecognizesSocketPath() throws {
- let path = makeTempSocketPath()
- let fd = try bindUnixSocket(at: path)
- defer {
- Darwin.close(fd)
- unlink(path)
- }
-
- let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: path)
- XCTAssertTrue(health.socketPathExists)
- XCTAssertFalse(health.isHealthy)
- }
-
- @MainActor
- func testSocketListenerHealthRejectsRegularFile() throws {
- let path = makeTempSocketPath()
- let url = URL(fileURLWithPath: path)
- try "not-a-socket".write(to: url, atomically: true, encoding: .utf8)
- defer { try? FileManager.default.removeItem(at: url) }
-
- let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: path)
- XCTAssertFalse(health.socketPathExists)
- XCTAssertFalse(health.isHealthy)
- }
-
- func testProbeSocketCommandReturnsFirstLineResponse() throws {
- let path = makeTempSocketPath()
- let listenerFD = try bindUnixSocket(at: path)
- defer {
- Darwin.close(listenerFD)
- unlink(path)
- }
-
- let handled = acceptSingleClient(on: listenerFD) { clientFD in
- var buffer = [UInt8](repeating: 0, count: 256)
- _ = read(clientFD, &buffer, buffer.count)
- let response = "PONG\nextra\n"
- _ = response.withCString { ptr in
- write(clientFD, ptr, strlen(ptr))
- }
- }
-
- let response = TerminalController.probeSocketCommand("ping", at: path, timeout: 0.5)
-
- XCTAssertEqual(response, "PONG")
- wait(for: [handled], timeout: 1.0)
- }
-
- func testProbeSocketCommandTimesOutWithoutPollingUntilServerResponds() throws {
- let path = makeTempSocketPath()
- let listenerFD = try bindUnixSocket(at: path)
- defer {
- Darwin.close(listenerFD)
- unlink(path)
- }
-
- let releaseServer = DispatchSemaphore(value: 0)
- let handled = acceptSingleClient(on: listenerFD) { clientFD in
- var buffer = [UInt8](repeating: 0, count: 256)
- _ = read(clientFD, &buffer, buffer.count)
- _ = releaseServer.wait(timeout: .now() + 1.0)
- }
-
- let startedAt = Date()
- let response = TerminalController.probeSocketCommand("ping", at: path, timeout: 0.2)
- let elapsed = Date().timeIntervalSince(startedAt)
- releaseServer.signal()
-
- XCTAssertNil(response)
- XCTAssertGreaterThanOrEqual(elapsed, 0.18)
- XCTAssertLessThan(elapsed, 0.8)
- wait(for: [handled], timeout: 1.0)
- }
-
- func testSocketListenerHealthFailureSignalsAreEmptyWhenHealthy() {
- let health = TerminalController.SocketListenerHealth(
- isRunning: true,
- acceptLoopAlive: true,
- socketPathMatches: true,
- socketPathExists: true
- )
- XCTAssertTrue(health.isHealthy)
- XCTAssertTrue(health.failureSignals.isEmpty)
- }
-
- func testSocketListenerHealthFailureSignalsIncludeAllDetectedProblems() {
- let health = TerminalController.SocketListenerHealth(
- isRunning: false,
- acceptLoopAlive: false,
- socketPathMatches: false,
- socketPathExists: false
- )
- XCTAssertFalse(health.isHealthy)
- XCTAssertEqual(
- health.failureSignals,
- ["not_running", "accept_loop_dead", "socket_path_mismatch", "socket_missing"]
- )
- }
-}
diff --git a/cmuxTests/NotificationAndMenuBarTests.swift b/cmuxTests/NotificationAndMenuBarTests.swift
new file mode 100644
index 00000000..c519e3ab
--- /dev/null
+++ b/cmuxTests/NotificationAndMenuBarTests.swift
@@ -0,0 +1,832 @@
+import XCTest
+import AppKit
+import SwiftUI
+import UniformTypeIdentifiers
+import WebKit
+import ObjectiveC.runtime
+import Bonsplit
+import UserNotifications
+
+#if canImport(cmux_DEV)
+@testable import cmux_DEV
+#elseif canImport(cmux)
+@testable import cmux
+#endif
+
+@MainActor
+final class NotificationDockBadgeTests: XCTestCase {
+ private final class NotificationSettingsAlertSpy: NSAlert {
+ private(set) var beginSheetModalCallCount = 0
+ private(set) var runModalCallCount = 0
+ var nextResponse: NSApplication.ModalResponse = .alertFirstButtonReturn
+
+ override func beginSheetModal(
+ for sheetWindow: NSWindow,
+ completionHandler handler: ((NSApplication.ModalResponse) -> Void)?
+ ) {
+ beginSheetModalCallCount += 1
+ handler?(nextResponse)
+ }
+
+ override func runModal() -> NSApplication.ModalResponse {
+ runModalCallCount += 1
+ return nextResponse
+ }
+ }
+
+ override func tearDown() {
+ TerminalNotificationStore.shared.resetNotificationSettingsPromptHooksForTesting()
+ TerminalNotificationStore.shared.replaceNotificationsForTesting([])
+ super.tearDown()
+ }
+
+ func testDockBadgeLabelEnabledAndCounted() {
+ XCTAssertEqual(TerminalNotificationStore.dockBadgeLabel(unreadCount: 1, isEnabled: true), "1")
+ XCTAssertEqual(TerminalNotificationStore.dockBadgeLabel(unreadCount: 42, isEnabled: true), "42")
+ XCTAssertEqual(TerminalNotificationStore.dockBadgeLabel(unreadCount: 100, isEnabled: true), "99+")
+ }
+
+ func testDockBadgeLabelHiddenWhenDisabledOrZero() {
+ XCTAssertNil(TerminalNotificationStore.dockBadgeLabel(unreadCount: 0, isEnabled: true))
+ XCTAssertNil(TerminalNotificationStore.dockBadgeLabel(unreadCount: 5, isEnabled: false))
+ }
+
+ func testDockBadgeLabelShowsRunTagEvenWithoutUnread() {
+ XCTAssertEqual(
+ TerminalNotificationStore.dockBadgeLabel(unreadCount: 0, isEnabled: true, runTag: "verify-tag"),
+ "verify-tag"
+ )
+ }
+
+ func testDockBadgeLabelCombinesRunTagAndUnreadCount() {
+ XCTAssertEqual(
+ TerminalNotificationStore.dockBadgeLabel(unreadCount: 7, isEnabled: true, runTag: "verify"),
+ "verify:7"
+ )
+ XCTAssertEqual(
+ TerminalNotificationStore.dockBadgeLabel(unreadCount: 120, isEnabled: true, runTag: "verify"),
+ "verify:99+"
+ )
+ }
+
+ func testNotificationBadgePreferenceDefaultsToEnabled() {
+ let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer {
+ defaults.removePersistentDomain(forName: suiteName)
+ }
+
+ XCTAssertTrue(NotificationBadgeSettings.isDockBadgeEnabled(defaults: defaults))
+
+ defaults.set(false, forKey: NotificationBadgeSettings.dockBadgeEnabledKey)
+ XCTAssertFalse(NotificationBadgeSettings.isDockBadgeEnabled(defaults: defaults))
+
+ defaults.set(true, forKey: NotificationBadgeSettings.dockBadgeEnabledKey)
+ XCTAssertTrue(NotificationBadgeSettings.isDockBadgeEnabled(defaults: defaults))
+ }
+
+ func testNotificationPaneFlashPreferenceDefaultsToEnabled() {
+ let suiteName = "NotificationPaneFlashSettingsTests.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer {
+ defaults.removePersistentDomain(forName: suiteName)
+ }
+
+ XCTAssertTrue(NotificationPaneFlashSettings.isEnabled(defaults: defaults))
+
+ defaults.set(false, forKey: NotificationPaneFlashSettings.enabledKey)
+ XCTAssertFalse(NotificationPaneFlashSettings.isEnabled(defaults: defaults))
+
+ defaults.set(true, forKey: NotificationPaneFlashSettings.enabledKey)
+ XCTAssertTrue(NotificationPaneFlashSettings.isEnabled(defaults: defaults))
+ }
+
+ func testMenuBarExtraPreferenceDefaultsToVisible() {
+ let suiteName = "MenuBarExtraVisibilityTests.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer {
+ defaults.removePersistentDomain(forName: suiteName)
+ }
+
+ XCTAssertTrue(MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults))
+
+ defaults.set(false, forKey: MenuBarExtraSettings.showInMenuBarKey)
+ XCTAssertFalse(MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults))
+
+ defaults.set(true, forKey: MenuBarExtraSettings.showInMenuBarKey)
+ XCTAssertTrue(MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults))
+ }
+
+ func testNotificationSoundUsesSystemSoundForDefaultAndNamedSounds() {
+ let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer {
+ defaults.removePersistentDomain(forName: suiteName)
+ }
+
+ XCTAssertTrue(NotificationSoundSettings.usesSystemSound(defaults: defaults))
+
+ defaults.set("Ping", forKey: NotificationSoundSettings.key)
+ XCTAssertTrue(NotificationSoundSettings.usesSystemSound(defaults: defaults))
+ XCTAssertNotNil(NotificationSoundSettings.sound(defaults: defaults))
+ }
+
+ func testNotificationSoundDisablesSystemSoundForNoneAndCustomFile() {
+ let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer {
+ defaults.removePersistentDomain(forName: suiteName)
+ }
+
+ defaults.set("none", forKey: NotificationSoundSettings.key)
+ XCTAssertFalse(NotificationSoundSettings.usesSystemSound(defaults: defaults))
+ XCTAssertNil(NotificationSoundSettings.sound(defaults: defaults))
+
+ defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key)
+ XCTAssertFalse(NotificationSoundSettings.usesSystemSound(defaults: defaults))
+ XCTAssertNil(NotificationSoundSettings.sound(defaults: defaults))
+ }
+
+ func testNotificationCustomFileURLExpandsTildePath() {
+ let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer {
+ defaults.removePersistentDomain(forName: suiteName)
+ }
+
+ let rawPath = "~/Library/Sounds/my-custom.wav"
+ defaults.set(rawPath, forKey: NotificationSoundSettings.customFilePathKey)
+ let expectedPath = (rawPath as NSString).expandingTildeInPath
+ XCTAssertEqual(NotificationSoundSettings.customFileURL(defaults: defaults)?.path, expectedPath)
+ }
+
+ func testNotificationCustomFileSelectionMustBeExplicit() {
+ let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer {
+ defaults.removePersistentDomain(forName: suiteName)
+ }
+
+ defaults.set("~/Library/Sounds/my-custom.wav", forKey: NotificationSoundSettings.customFilePathKey)
+
+ defaults.set("none", forKey: NotificationSoundSettings.key)
+ XCTAssertFalse(NotificationSoundSettings.isCustomFileSelected(defaults: defaults))
+
+ defaults.set("Ping", forKey: NotificationSoundSettings.key)
+ XCTAssertFalse(NotificationSoundSettings.isCustomFileSelected(defaults: defaults))
+
+ defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key)
+ XCTAssertTrue(NotificationSoundSettings.isCustomFileSelected(defaults: defaults))
+ }
+
+ func testNotificationCustomStagingPreservesSourceFileWithCmuxPrefix() {
+ let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer {
+ defaults.removePersistentDomain(forName: suiteName)
+ }
+
+ let fileManager = FileManager.default
+ let soundsDirectory = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
+ .appendingPathComponent("Library", isDirectory: true)
+ .appendingPathComponent("Sounds", isDirectory: true)
+ do {
+ try fileManager.createDirectory(at: soundsDirectory, withIntermediateDirectories: true)
+ } catch {
+ XCTFail("Failed to create sounds directory: \(error)")
+ return
+ }
+
+ let sourceURL = soundsDirectory.appendingPathComponent(
+ "cmux-custom-notification-sound.source-\(UUID().uuidString).wav",
+ isDirectory: false
+ )
+ defer {
+ try? fileManager.removeItem(at: sourceURL)
+ }
+
+ do {
+ try Data("test".utf8).write(to: sourceURL, options: .atomic)
+ } catch {
+ XCTFail("Failed to write source custom sound file: \(error)")
+ return
+ }
+
+ defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key)
+ defaults.set(sourceURL.path, forKey: NotificationSoundSettings.customFilePathKey)
+
+ _ = NotificationSoundSettings.sound(defaults: defaults)
+
+ guard let stagedName = NotificationSoundSettings.stagedCustomSoundName(defaults: defaults) else {
+ XCTFail("Expected staged custom sound name")
+ return
+ }
+ let stagedURL = soundsDirectory.appendingPathComponent(stagedName, isDirectory: false)
+ defer {
+ try? fileManager.removeItem(at: stagedURL)
+ }
+
+ XCTAssertTrue(fileManager.fileExists(atPath: sourceURL.path))
+ XCTAssertTrue(fileManager.fileExists(atPath: stagedURL.path))
+ XCTAssertTrue(stagedName.hasPrefix("cmux-custom-notification-sound-"))
+ XCTAssertTrue(stagedName.hasSuffix(".wav"))
+ }
+
+ func testNotificationCustomUnsupportedExtensionsStageAsCaf() {
+ XCTAssertEqual(
+ NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "mp3"),
+ "caf"
+ )
+ XCTAssertEqual(
+ NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "M4A"),
+ "caf"
+ )
+ XCTAssertEqual(
+ NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "wav"),
+ "wav"
+ )
+ XCTAssertEqual(
+ NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "AIFF"),
+ "aiff"
+ )
+
+ let sourceA = URL(fileURLWithPath: "/tmp/custom-a.mp3")
+ let sourceB = URL(fileURLWithPath: "/tmp/custom-b.mp3")
+ let stagedA = NotificationSoundSettings.stagedCustomSoundFileName(
+ forSourceURL: sourceA,
+ destinationExtension: "caf"
+ )
+ let stagedB = NotificationSoundSettings.stagedCustomSoundFileName(
+ forSourceURL: sourceB,
+ destinationExtension: "caf"
+ )
+ XCTAssertNotEqual(stagedA, stagedB)
+ XCTAssertTrue(stagedA.hasPrefix("cmux-custom-notification-sound-"))
+ XCTAssertTrue(stagedA.hasSuffix(".caf"))
+ }
+
+ func testNotificationCustomPreparationKeepsActiveSourceMetadataSidecar() {
+ let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer {
+ defaults.removePersistentDomain(forName: suiteName)
+ }
+
+ let fileManager = FileManager.default
+ let soundsDirectory = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
+ .appendingPathComponent("Library", isDirectory: true)
+ .appendingPathComponent("Sounds", isDirectory: true)
+ do {
+ try fileManager.createDirectory(at: soundsDirectory, withIntermediateDirectories: true)
+ } catch {
+ XCTFail("Failed to create sounds directory: \(error)")
+ return
+ }
+
+ let sourceURL = soundsDirectory.appendingPathComponent(
+ "cmux-custom-notification-sound.metadata-\(UUID().uuidString).wav",
+ isDirectory: false
+ )
+ do {
+ try Data("test".utf8).write(to: sourceURL, options: .atomic)
+ } catch {
+ XCTFail("Failed to write source custom sound file: \(error)")
+ return
+ }
+ defer {
+ try? fileManager.removeItem(at: sourceURL)
+ }
+
+ defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key)
+ defaults.set(sourceURL.path, forKey: NotificationSoundSettings.customFilePathKey)
+
+ let prepareResult = NotificationSoundSettings.prepareCustomFileForNotifications(path: sourceURL.path)
+ let stagedName: String
+ switch prepareResult {
+ case .success(let name):
+ stagedName = name
+ case .failure(let issue):
+ XCTFail("Expected custom sound preparation success, got \(issue)")
+ return
+ }
+
+ let stagedURL = soundsDirectory.appendingPathComponent(stagedName, isDirectory: false)
+ let metadataURL = stagedURL.appendingPathExtension("source-metadata")
+ defer {
+ try? fileManager.removeItem(at: stagedURL)
+ try? fileManager.removeItem(at: metadataURL)
+ }
+
+ XCTAssertTrue(fileManager.fileExists(atPath: stagedURL.path))
+ XCTAssertTrue(fileManager.fileExists(atPath: metadataURL.path))
+ }
+
+ func testNotificationCustomSoundReturnsNilWhenPreparationFails() {
+ let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer {
+ defaults.removePersistentDomain(forName: suiteName)
+ }
+
+ let invalidSourceURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent("cmux-invalid-sound-\(UUID().uuidString).mp3", isDirectory: false)
+ defer {
+ try? FileManager.default.removeItem(at: invalidSourceURL)
+ let stagedURL = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
+ .appendingPathComponent("Library", isDirectory: true)
+ .appendingPathComponent("Sounds", isDirectory: true)
+ .appendingPathComponent("cmux-custom-notification-sound.caf", isDirectory: false)
+ try? FileManager.default.removeItem(at: stagedURL)
+ }
+
+ do {
+ try Data("not-audio".utf8).write(to: invalidSourceURL, options: .atomic)
+ } catch {
+ XCTFail("Failed to write invalid custom sound source: \(error)")
+ return
+ }
+
+ defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key)
+ defaults.set(invalidSourceURL.path, forKey: NotificationSoundSettings.customFilePathKey)
+
+ XCTAssertNil(NotificationSoundSettings.sound(defaults: defaults))
+ }
+
+ func testNotificationCustomPreparationReportsMissingFile() {
+ let missingPath = FileManager.default.temporaryDirectory
+ .appendingPathComponent("cmux-missing-\(UUID().uuidString).wav", isDirectory: false)
+ .path
+
+ let result = NotificationSoundSettings.prepareCustomFileForNotifications(path: missingPath)
+ switch result {
+ case .success:
+ XCTFail("Expected missing file failure")
+ case .failure(let issue):
+ guard case .missingFile = issue else {
+ XCTFail("Expected missingFile issue, got \(issue)")
+ return
+ }
+ }
+ }
+
+ func testNotificationAuthorizationStateMappingCoversKnownUNAuthorizationStatuses() {
+ XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .notDetermined), .notDetermined)
+ XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .denied), .denied)
+ XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .authorized), .authorized)
+ XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .provisional), .provisional)
+ }
+
+ func testNotificationAuthorizationStateDeliveryCapability() {
+ XCTAssertFalse(NotificationAuthorizationState.unknown.allowsDelivery)
+ XCTAssertFalse(NotificationAuthorizationState.notDetermined.allowsDelivery)
+ XCTAssertFalse(NotificationAuthorizationState.denied.allowsDelivery)
+ XCTAssertTrue(NotificationAuthorizationState.authorized.allowsDelivery)
+ XCTAssertTrue(NotificationAuthorizationState.provisional.allowsDelivery)
+ XCTAssertTrue(NotificationAuthorizationState.ephemeral.allowsDelivery)
+ }
+
+ func testNotificationAuthorizationDefersFirstPromptWhileAppIsInactive() {
+ XCTAssertTrue(
+ TerminalNotificationStore.shouldDeferAutomaticAuthorizationRequest(
+ status: .notDetermined,
+ isAppActive: false
+ )
+ )
+ XCTAssertFalse(
+ TerminalNotificationStore.shouldDeferAutomaticAuthorizationRequest(
+ status: .notDetermined,
+ isAppActive: true
+ )
+ )
+ XCTAssertFalse(
+ TerminalNotificationStore.shouldDeferAutomaticAuthorizationRequest(
+ status: .authorized,
+ isAppActive: false
+ )
+ )
+ }
+
+ func testNotificationAuthorizationRequestGatingAllowsSettingsRetry() {
+ XCTAssertTrue(
+ TerminalNotificationStore.shouldRequestAuthorization(
+ isAutomaticRequest: false,
+ hasRequestedAutomaticAuthorization: true
+ )
+ )
+ XCTAssertTrue(
+ TerminalNotificationStore.shouldRequestAuthorization(
+ isAutomaticRequest: true,
+ hasRequestedAutomaticAuthorization: false
+ )
+ )
+ XCTAssertFalse(
+ TerminalNotificationStore.shouldRequestAuthorization(
+ isAutomaticRequest: true,
+ hasRequestedAutomaticAuthorization: true
+ )
+ )
+ }
+
+ func testNotificationSettingsPromptUsesSheetAndNeverRunsModal() {
+ let store = TerminalNotificationStore.shared
+ let alertSpy = NotificationSettingsAlertSpy()
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 480, height: 320),
+ styleMask: [.titled],
+ backing: .buffered,
+ defer: false
+ )
+
+ var openedURL: URL?
+ store.configureNotificationSettingsPromptHooksForTesting(
+ windowProvider: { window },
+ alertFactory: { alertSpy },
+ scheduler: { _, block in block() },
+ urlOpener: { openedURL = $0 }
+ )
+
+ store.promptToEnableNotificationsForTesting()
+ let drained = expectation(description: "main queue drained")
+ DispatchQueue.main.async { drained.fulfill() }
+ wait(for: [drained], timeout: 1.0)
+
+ XCTAssertEqual(alertSpy.beginSheetModalCallCount, 1)
+ XCTAssertEqual(alertSpy.runModalCallCount, 0)
+ XCTAssertEqual(
+ openedURL?.absoluteString,
+ "x-apple.systempreferences:com.apple.preference.notifications"
+ )
+ }
+
+ func testNotificationSettingsPromptRetriesUntilWindowExists() {
+ let store = TerminalNotificationStore.shared
+ let alertSpy = NotificationSettingsAlertSpy()
+ alertSpy.nextResponse = .alertSecondButtonReturn
+
+ var queuedRetryBlocks: [() -> Void] = []
+ var promptWindow: NSWindow?
+ store.configureNotificationSettingsPromptHooksForTesting(
+ windowProvider: { promptWindow },
+ alertFactory: { alertSpy },
+ scheduler: { _, block in queuedRetryBlocks.append(block) },
+ urlOpener: { _ in XCTFail("Should not open settings for Not Now response") }
+ )
+
+ store.promptToEnableNotificationsForTesting()
+ let drained = expectation(description: "main queue drained")
+ DispatchQueue.main.async { drained.fulfill() }
+ wait(for: [drained], timeout: 1.0)
+
+ XCTAssertEqual(alertSpy.beginSheetModalCallCount, 0)
+ XCTAssertEqual(alertSpy.runModalCallCount, 0)
+ XCTAssertEqual(queuedRetryBlocks.count, 1)
+
+ promptWindow = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 480, height: 320),
+ styleMask: [.titled],
+ backing: .buffered,
+ defer: false
+ )
+ queuedRetryBlocks.removeFirst()()
+
+ XCTAssertEqual(alertSpy.beginSheetModalCallCount, 1)
+ XCTAssertEqual(alertSpy.runModalCallCount, 0)
+ }
+
+ func testNotificationIndexesTrackUnreadCountsByTabAndSurface() {
+ let tabA = UUID()
+ let tabB = UUID()
+ let surfaceA = UUID()
+ let surfaceB = UUID()
+ let notificationAUnread = TerminalNotification(
+ id: UUID(),
+ tabId: tabA,
+ surfaceId: surfaceA,
+ title: "A unread",
+ subtitle: "",
+ body: "",
+ createdAt: Date(),
+ isRead: false
+ )
+ let notificationARead = TerminalNotification(
+ id: UUID(),
+ tabId: tabA,
+ surfaceId: surfaceB,
+ title: "A read",
+ subtitle: "",
+ body: "",
+ createdAt: Date(),
+ isRead: true
+ )
+ let notificationBUnread = TerminalNotification(
+ id: UUID(),
+ tabId: tabB,
+ surfaceId: nil,
+ title: "B unread",
+ subtitle: "",
+ body: "",
+ createdAt: Date(),
+ isRead: false
+ )
+
+ let store = TerminalNotificationStore.shared
+ store.replaceNotificationsForTesting([
+ notificationAUnread,
+ notificationARead,
+ notificationBUnread
+ ])
+
+ XCTAssertEqual(store.unreadCount, 2)
+ XCTAssertEqual(store.unreadCount(forTabId: tabA), 1)
+ XCTAssertEqual(store.unreadCount(forTabId: tabB), 1)
+ XCTAssertTrue(store.hasUnreadNotification(forTabId: tabA, surfaceId: surfaceA))
+ XCTAssertFalse(store.hasUnreadNotification(forTabId: tabA, surfaceId: surfaceB))
+ XCTAssertTrue(store.hasUnreadNotification(forTabId: tabB, surfaceId: nil))
+ XCTAssertEqual(store.latestNotification(forTabId: tabA)?.id, notificationAUnread.id)
+ XCTAssertEqual(store.latestNotification(forTabId: tabB)?.id, notificationBUnread.id)
+ }
+
+ func testNotificationIndexesUpdateAfterReadAndClearMutations() {
+ let tab = UUID()
+ let surfaceUnread = UUID()
+ let surfaceRead = UUID()
+ let unreadNotification = TerminalNotification(
+ id: UUID(),
+ tabId: tab,
+ surfaceId: surfaceUnread,
+ title: "Unread",
+ subtitle: "",
+ body: "",
+ createdAt: Date(),
+ isRead: false
+ )
+ let readNotification = TerminalNotification(
+ id: UUID(),
+ tabId: tab,
+ surfaceId: surfaceRead,
+ title: "Read",
+ subtitle: "",
+ body: "",
+ createdAt: Date(),
+ isRead: true
+ )
+
+ let store = TerminalNotificationStore.shared
+ store.replaceNotificationsForTesting([unreadNotification, readNotification])
+ XCTAssertEqual(store.unreadCount(forTabId: tab), 1)
+ XCTAssertTrue(store.hasUnreadNotification(forTabId: tab, surfaceId: surfaceUnread))
+
+ store.markRead(forTabId: tab, surfaceId: surfaceUnread)
+ XCTAssertEqual(store.unreadCount(forTabId: tab), 0)
+ XCTAssertFalse(store.hasUnreadNotification(forTabId: tab, surfaceId: surfaceUnread))
+ XCTAssertEqual(store.latestNotification(forTabId: tab)?.id, unreadNotification.id)
+
+ store.clearNotifications(forTabId: tab)
+ XCTAssertEqual(store.unreadCount(forTabId: tab), 0)
+ XCTAssertNil(store.latestNotification(forTabId: tab))
+ }
+}
+
+
+final class MenuBarBadgeLabelFormatterTests: XCTestCase {
+ func testBadgeLabelFormatting() {
+ XCTAssertNil(MenuBarBadgeLabelFormatter.badgeText(for: 0))
+ XCTAssertEqual(MenuBarBadgeLabelFormatter.badgeText(for: 1), "1")
+ XCTAssertEqual(MenuBarBadgeLabelFormatter.badgeText(for: 9), "9")
+ XCTAssertEqual(MenuBarBadgeLabelFormatter.badgeText(for: 10), "9+")
+ XCTAssertEqual(MenuBarBadgeLabelFormatter.badgeText(for: 47), "9+")
+ }
+}
+
+
+final class NotificationMenuSnapshotBuilderTests: XCTestCase {
+ func testSnapshotCountsUnreadAndLimitsRecentItems() {
+ let notifications = (0..<8).map { index in
+ TerminalNotification(
+ id: UUID(),
+ tabId: UUID(),
+ surfaceId: nil,
+ title: "N\(index)",
+ subtitle: "",
+ body: "",
+ createdAt: Date(timeIntervalSince1970: TimeInterval(index)),
+ isRead: index.isMultiple(of: 2)
+ )
+ }
+
+ let snapshot = NotificationMenuSnapshotBuilder.make(
+ notifications: notifications,
+ maxInlineNotificationItems: 3
+ )
+
+ XCTAssertEqual(snapshot.unreadCount, 4)
+ XCTAssertTrue(snapshot.hasNotifications)
+ XCTAssertTrue(snapshot.hasUnreadNotifications)
+ XCTAssertEqual(snapshot.recentNotifications.count, 3)
+ XCTAssertEqual(snapshot.recentNotifications.map(\.id), Array(notifications.prefix(3)).map(\.id))
+ }
+
+ func testStateHintTitleHandlesSingularPluralAndZero() {
+ XCTAssertEqual(NotificationMenuSnapshotBuilder.stateHintTitle(unreadCount: 0), "No unread notifications")
+ XCTAssertEqual(NotificationMenuSnapshotBuilder.stateHintTitle(unreadCount: 1), "1 unread notification")
+ XCTAssertEqual(NotificationMenuSnapshotBuilder.stateHintTitle(unreadCount: 2), "2 unread notifications")
+ }
+}
+
+
+final class MenuBarBuildHintFormatterTests: XCTestCase {
+ func testReleaseBuildShowsNoHint() {
+ XCTAssertNil(MenuBarBuildHintFormatter.menuTitle(appName: "cmux DEV menubar-extra", isDebugBuild: false))
+ }
+
+ func testDebugBuildWithTagShowsTag() {
+ XCTAssertEqual(
+ MenuBarBuildHintFormatter.menuTitle(appName: "cmux DEV menubar-extra", isDebugBuild: true),
+ "Build Tag: menubar-extra"
+ )
+ }
+
+ func testDebugBuildWithoutTagShowsUntagged() {
+ XCTAssertEqual(
+ MenuBarBuildHintFormatter.menuTitle(appName: "cmux DEV", isDebugBuild: true),
+ "Build: DEV (untagged)"
+ )
+ }
+}
+
+
+final class MenuBarNotificationLineFormatterTests: XCTestCase {
+ func testPlainTitleContainsUnreadDotBodyAndTab() {
+ let notification = TerminalNotification(
+ id: UUID(),
+ tabId: UUID(),
+ surfaceId: nil,
+ title: "Build finished",
+ subtitle: "",
+ body: "All checks passed",
+ createdAt: Date(timeIntervalSince1970: 0),
+ isRead: false
+ )
+
+ let line = MenuBarNotificationLineFormatter.plainTitle(notification: notification, tabTitle: "workspace-1")
+ XCTAssertTrue(line.hasPrefix("● Build finished"))
+ XCTAssertTrue(line.contains("All checks passed"))
+ XCTAssertTrue(line.contains("workspace-1"))
+ }
+
+ func testPlainTitleFallsBackToSubtitleWhenBodyEmpty() {
+ let notification = TerminalNotification(
+ id: UUID(),
+ tabId: UUID(),
+ surfaceId: nil,
+ title: "Deploy",
+ subtitle: "staging",
+ body: "",
+ createdAt: Date(timeIntervalSince1970: 0),
+ isRead: true
+ )
+
+ let line = MenuBarNotificationLineFormatter.plainTitle(notification: notification, tabTitle: nil)
+ XCTAssertTrue(line.hasPrefix(" Deploy"))
+ XCTAssertTrue(line.contains("staging"))
+ }
+
+ func testMenuTitleWrapsAndTruncatesToThreeLines() {
+ let notification = TerminalNotification(
+ id: UUID(),
+ tabId: UUID(),
+ surfaceId: nil,
+ title: "Extremely long notification title for wrapping behavior validation",
+ subtitle: "",
+ body: Array(repeating: "this body should wrap and eventually truncate", count: 8).joined(separator: " "),
+ createdAt: Date(timeIntervalSince1970: 0),
+ isRead: false
+ )
+
+ let title = MenuBarNotificationLineFormatter.menuTitle(
+ notification: notification,
+ tabTitle: "workspace-with-a-very-long-name",
+ maxWidth: 120,
+ maxLines: 3
+ )
+
+ XCTAssertLessThanOrEqual(title.components(separatedBy: "\n").count, 3)
+ XCTAssertTrue(title.hasSuffix("…"))
+ }
+
+ func testMenuTitlePreservesShortTextWithoutEllipsis() {
+ let notification = TerminalNotification(
+ id: UUID(),
+ tabId: UUID(),
+ surfaceId: nil,
+ title: "Done",
+ subtitle: "",
+ body: "All checks passed",
+ createdAt: Date(timeIntervalSince1970: 0),
+ isRead: false
+ )
+
+ let title = MenuBarNotificationLineFormatter.menuTitle(
+ notification: notification,
+ tabTitle: "w1",
+ maxWidth: 320,
+ maxLines: 3
+ )
+
+ XCTAssertFalse(title.hasSuffix("…"))
+ }
+}
+
+
+final class MenuBarIconDebugSettingsTests: XCTestCase {
+ func testDisplayedUnreadCountUsesPreviewOverrideWhenEnabled() {
+ let suiteName = "MenuBarIconDebugSettingsTests.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ defaults.set(true, forKey: MenuBarIconDebugSettings.previewEnabledKey)
+ defaults.set(7, forKey: MenuBarIconDebugSettings.previewCountKey)
+
+ XCTAssertEqual(MenuBarIconDebugSettings.displayedUnreadCount(actualUnreadCount: 2, defaults: defaults), 7)
+ }
+
+ func testBadgeRenderConfigClampsInvalidValues() {
+ let suiteName = "MenuBarIconDebugSettingsTests.Clamp.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ defaults.set(-100, forKey: MenuBarIconDebugSettings.badgeRectXKey)
+ defaults.set(200, forKey: MenuBarIconDebugSettings.badgeRectYKey)
+ defaults.set(-100, forKey: MenuBarIconDebugSettings.singleDigitFontSizeKey)
+ defaults.set(100, forKey: MenuBarIconDebugSettings.multiDigitXAdjustKey)
+
+ let config = MenuBarIconDebugSettings.badgeRenderConfig(defaults: defaults)
+ XCTAssertEqual(config.badgeRect.origin.x, 0, accuracy: 0.001)
+ XCTAssertEqual(config.badgeRect.origin.y, 20, accuracy: 0.001)
+ XCTAssertEqual(config.singleDigitFontSize, 6, accuracy: 0.001)
+ XCTAssertEqual(config.multiDigitXAdjust, 4, accuracy: 0.001)
+ }
+
+ func testBadgeRenderConfigUsesLegacySingleDigitXAdjustWhenNewKeyMissing() {
+ let suiteName = "MenuBarIconDebugSettingsTests.LegacyX.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ defaults.set(2.5, forKey: MenuBarIconDebugSettings.legacySingleDigitXAdjustKey)
+
+ let config = MenuBarIconDebugSettings.badgeRenderConfig(defaults: defaults)
+ XCTAssertEqual(config.singleDigitXAdjust, 2.5, accuracy: 0.001)
+ }
+}
+
+@MainActor
+
+
+final class MenuBarIconRendererTests: XCTestCase {
+ func testImageWidthDoesNotShiftWhenBadgeAppears() {
+ let noBadge = MenuBarIconRenderer.makeImage(unreadCount: 0)
+ let withBadge = MenuBarIconRenderer.makeImage(unreadCount: 2)
+
+ XCTAssertEqual(noBadge.size.width, 18, accuracy: 0.001)
+ XCTAssertEqual(withBadge.size.width, 18, accuracy: 0.001)
+ }
+}
diff --git a/cmuxTests/OmnibarAndToolsTests.swift b/cmuxTests/OmnibarAndToolsTests.swift
new file mode 100644
index 00000000..6b61a766
--- /dev/null
+++ b/cmuxTests/OmnibarAndToolsTests.swift
@@ -0,0 +1,857 @@
+import XCTest
+import AppKit
+import SwiftUI
+import UniformTypeIdentifiers
+import WebKit
+import ObjectiveC.runtime
+import Bonsplit
+import UserNotifications
+
+#if canImport(cmux_DEV)
+@testable import cmux_DEV
+#elseif canImport(cmux)
+@testable import cmux
+#endif
+
+final class FinderServicePathResolverTests: XCTestCase {
+ func testOrderedUniqueDirectoriesUsesParentForFilesAndDedupes() {
+ let input: [URL] = [
+ URL(fileURLWithPath: "/tmp/cmux-services/project", isDirectory: true),
+ URL(fileURLWithPath: "/tmp/cmux-services/project/README.md", isDirectory: false),
+ URL(fileURLWithPath: "/tmp/cmux-services/../cmux-services/project", isDirectory: true),
+ URL(fileURLWithPath: "/tmp/cmux-services/other", isDirectory: true),
+ ]
+
+ let directories = FinderServicePathResolver.orderedUniqueDirectories(from: input)
+ XCTAssertEqual(
+ directories,
+ [
+ "/tmp/cmux-services/project",
+ "/tmp/cmux-services/other",
+ ]
+ )
+ }
+
+ func testOrderedUniqueDirectoriesPreservesFirstSeenOrder() {
+ let input: [URL] = [
+ URL(fileURLWithPath: "/tmp/cmux-services/b", isDirectory: true),
+ URL(fileURLWithPath: "/tmp/cmux-services/a/file.txt", isDirectory: false),
+ URL(fileURLWithPath: "/tmp/cmux-services/a", isDirectory: true),
+ URL(fileURLWithPath: "/tmp/cmux-services/b/file.txt", isDirectory: false),
+ ]
+
+ let directories = FinderServicePathResolver.orderedUniqueDirectories(from: input)
+ XCTAssertEqual(
+ directories,
+ [
+ "/tmp/cmux-services/b",
+ "/tmp/cmux-services/a",
+ ]
+ )
+ }
+}
+
+
+final class VSCodeServeWebURLBuilderTests: XCTestCase {
+ func testExtractWebUIURLParsesServeWebOutput() {
+ let output = """
+ *
+ * Visual Studio Code Server
+ *
+ Web UI available at http://127.0.0.1:5555?tkn=test-token
+ """
+
+ let url = VSCodeServeWebURLBuilder.extractWebUIURL(from: output)
+ XCTAssertEqual(url?.absoluteString, "http://127.0.0.1:5555?tkn=test-token")
+ }
+
+ func testOpenFolderURLAppendsFolderQueryWhilePreservingToken() {
+ let baseURL = URL(string: "http://127.0.0.1:5555?tkn=test-token")!
+
+ let url = VSCodeServeWebURLBuilder.openFolderURL(
+ baseWebUIURL: baseURL,
+ directoryPath: "/Users/tester/Projects/cmux"
+ )
+
+ let components = URLComponents(url: url!, resolvingAgainstBaseURL: false)
+ XCTAssertEqual(components?.queryItems?.first(where: { $0.name == "tkn" })?.value, "test-token")
+ XCTAssertEqual(components?.queryItems?.first(where: { $0.name == "folder" })?.value, "/Users/tester/Projects/cmux")
+ }
+
+ func testOpenFolderURLReplacesExistingFolderQuery() {
+ let baseURL = URL(string: "http://127.0.0.1:5555?tkn=test-token&folder=/tmp/old")!
+
+ let url = VSCodeServeWebURLBuilder.openFolderURL(
+ baseWebUIURL: baseURL,
+ directoryPath: "/Users/tester/New Folder"
+ )
+
+ let components = URLComponents(url: url!, resolvingAgainstBaseURL: false)
+ XCTAssertEqual(
+ components?.queryItems?.filter { $0.name == "folder" }.count,
+ 1
+ )
+ XCTAssertEqual(
+ components?.queryItems?.first(where: { $0.name == "folder" })?.value,
+ "/Users/tester/New Folder"
+ )
+ }
+}
+
+
+final class VSCodeCLILaunchConfigurationBuilderTests: XCTestCase {
+ func testLaunchConfigurationUsesCodeTunnelBinary() {
+ let appURL = URL(fileURLWithPath: "/Applications/Visual Studio Code.app", isDirectory: true)
+ let expectedExecutablePath = "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code-tunnel"
+
+ let configuration = VSCodeCLILaunchConfigurationBuilder.launchConfiguration(
+ vscodeApplicationURL: appURL,
+ baseEnvironment: [:],
+ isExecutableAtPath: { $0 == expectedExecutablePath }
+ )
+
+ XCTAssertEqual(configuration?.executableURL.path, expectedExecutablePath)
+ XCTAssertEqual(configuration?.argumentsPrefix, [])
+ XCTAssertEqual(configuration?.environment["ELECTRON_RUN_AS_NODE"], "1")
+ }
+
+ func testLaunchConfigurationMapsNodeEnvironmentVariables() {
+ let configuration = VSCodeCLILaunchConfigurationBuilder.launchConfiguration(
+ vscodeApplicationURL: URL(fileURLWithPath: "/Applications/Visual Studio Code.app", isDirectory: true),
+ baseEnvironment: [
+ "PATH": "/usr/bin:/bin",
+ "NODE_OPTIONS": "--max-old-space-size=4096",
+ "NODE_REPL_EXTERNAL_MODULE": "module-name"
+ ],
+ isExecutableAtPath: { _ in true }
+ )
+
+ XCTAssertEqual(configuration?.environment["PATH"], "/usr/bin:/bin")
+ XCTAssertEqual(configuration?.environment["VSCODE_NODE_OPTIONS"], "--max-old-space-size=4096")
+ XCTAssertEqual(configuration?.environment["VSCODE_NODE_REPL_EXTERNAL_MODULE"], "module-name")
+ XCTAssertNil(configuration?.environment["NODE_OPTIONS"])
+ XCTAssertNil(configuration?.environment["NODE_REPL_EXTERNAL_MODULE"])
+ }
+
+ func testLaunchConfigurationClearsStaleVSCodeNodeVariablesWhenNodeVariablesAreAbsent() {
+ let configuration = VSCodeCLILaunchConfigurationBuilder.launchConfiguration(
+ vscodeApplicationURL: URL(fileURLWithPath: "/Applications/Visual Studio Code.app", isDirectory: true),
+ baseEnvironment: [
+ "PATH": "/usr/bin:/bin",
+ "VSCODE_NODE_OPTIONS": "--stale",
+ "VSCODE_NODE_REPL_EXTERNAL_MODULE": "stale-module"
+ ],
+ isExecutableAtPath: { _ in true }
+ )
+
+ XCTAssertEqual(configuration?.environment["PATH"], "/usr/bin:/bin")
+ XCTAssertNil(configuration?.environment["VSCODE_NODE_OPTIONS"])
+ XCTAssertNil(configuration?.environment["VSCODE_NODE_REPL_EXTERNAL_MODULE"])
+ }
+}
+
+
+final class ServeWebOutputCollectorTests: XCTestCase {
+ func testWaitForURLReturnsFalseAfterProcessExitSignal() {
+ let collector = ServeWebOutputCollector()
+
+ DispatchQueue.global().asyncAfter(deadline: .now() + 0.05) {
+ collector.markProcessExited()
+ }
+
+ let start = Date()
+ let resolved = collector.waitForURL(timeoutSeconds: 1)
+ let elapsed = Date().timeIntervalSince(start)
+
+ XCTAssertFalse(resolved)
+ XCTAssertLessThan(elapsed, 0.5)
+ }
+
+ func testWaitForURLReturnsTrueWhenURLIsCollected() {
+ let collector = ServeWebOutputCollector()
+ let urlLine = "Web UI available at http://127.0.0.1:7777?tkn=test-token\n"
+
+ DispatchQueue.global().asyncAfter(deadline: .now() + 0.05) {
+ collector.append(Data(urlLine.utf8))
+ }
+
+ XCTAssertTrue(collector.waitForURL(timeoutSeconds: 1))
+ XCTAssertEqual(collector.webUIURL?.absoluteString, "http://127.0.0.1:7777?tkn=test-token")
+ }
+
+ func testMarkProcessExitedParsesFinalURLWithoutTrailingNewline() {
+ let collector = ServeWebOutputCollector()
+ let finalChunk = "Web UI available at http://127.0.0.1:9001?tkn=final-token"
+
+ collector.append(Data(finalChunk.utf8))
+ collector.markProcessExited()
+
+ XCTAssertTrue(collector.waitForURL(timeoutSeconds: 0.1))
+ XCTAssertEqual(collector.webUIURL?.absoluteString, "http://127.0.0.1:9001?tkn=final-token")
+ }
+}
+
+
+final class VSCodeServeWebControllerTests: XCTestCase {
+ func testStopDuringInFlightLaunchDoesNotDropNextGenerationCompletion() {
+ let firstLaunchStarted = expectation(description: "first launch started")
+ let firstCompletionCalled = expectation(description: "first generation completion called")
+ let secondCompletionCalled = expectation(description: "second generation completion called")
+
+ let launchGate = DispatchSemaphore(value: 0)
+ let launchCallLock = NSLock()
+ var launchCallCount = 0
+
+ let controller = VSCodeServeWebController.makeForTesting { _, _ in
+ launchCallLock.lock()
+ launchCallCount += 1
+ let callNumber = launchCallCount
+ launchCallLock.unlock()
+
+ if callNumber == 1 {
+ firstLaunchStarted.fulfill()
+ _ = launchGate.wait(timeout: .now() + 1)
+ }
+ return nil
+ }
+
+ let callbackLock = NSLock()
+ var firstGenerationCallbacks: [URL?] = []
+ var secondGenerationCallbacks: [URL?] = []
+ let vscodeAppURL = URL(fileURLWithPath: "/Applications/Visual Studio Code.app", isDirectory: true)
+
+ controller.ensureServeWebURL(vscodeApplicationURL: vscodeAppURL) { url in
+ callbackLock.lock()
+ firstGenerationCallbacks.append(url)
+ callbackLock.unlock()
+ firstCompletionCalled.fulfill()
+ }
+
+ wait(for: [firstLaunchStarted], timeout: 1)
+ controller.stop()
+
+ controller.ensureServeWebURL(vscodeApplicationURL: vscodeAppURL) { url in
+ callbackLock.lock()
+ secondGenerationCallbacks.append(url)
+ callbackLock.unlock()
+ secondCompletionCalled.fulfill()
+ }
+
+ launchGate.signal()
+ wait(for: [firstCompletionCalled, secondCompletionCalled], timeout: 2)
+
+ callbackLock.lock()
+ let firstSnapshot = firstGenerationCallbacks
+ let secondSnapshot = secondGenerationCallbacks
+ callbackLock.unlock()
+
+ launchCallLock.lock()
+ let launchCalls = launchCallCount
+ launchCallLock.unlock()
+
+ XCTAssertEqual(firstSnapshot.count, 1)
+ if firstSnapshot.count == 1 {
+ XCTAssertNil(firstSnapshot[0])
+ }
+ XCTAssertEqual(secondSnapshot.count, 1)
+ if secondSnapshot.count == 1 {
+ XCTAssertNil(secondSnapshot[0])
+ }
+ XCTAssertEqual(launchCalls, 2)
+ }
+
+ func testStopRemovesOrphanedConnectionTokenFiles() throws {
+ let tokenFileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ defer { try? FileManager.default.removeItem(at: tokenFileURL) }
+ try Data("token".utf8).write(to: tokenFileURL)
+ XCTAssertTrue(FileManager.default.fileExists(atPath: tokenFileURL.path))
+
+ let controller = VSCodeServeWebController.makeForTesting { _, _ in
+ XCTFail("Expected no launch")
+ return nil
+ }
+ controller.trackConnectionTokenFileForTesting(tokenFileURL)
+
+ controller.stop()
+
+ XCTAssertFalse(FileManager.default.fileExists(atPath: tokenFileURL.path))
+ }
+}
+
+
+final class OmnibarStateMachineTests: XCTestCase {
+ func testEscapeRevertsWhenEditingThenBlursOnSecondEscape() throws {
+ var state = OmnibarState()
+
+ var effects = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://example.com/"))
+ XCTAssertTrue(state.isFocused)
+ XCTAssertEqual(state.buffer, "https://example.com/")
+ XCTAssertFalse(state.isUserEditing)
+ XCTAssertTrue(effects.shouldSelectAll)
+
+ effects = omnibarReduce(state: &state, event: .bufferChanged("exam"))
+ XCTAssertTrue(state.isUserEditing)
+ XCTAssertEqual(state.buffer, "exam")
+ XCTAssertTrue(effects.shouldRefreshSuggestions)
+
+ // Simulate an open popup.
+ effects = omnibarReduce(
+ state: &state,
+ event: .suggestionsUpdated([.search(engineName: "Google", query: "exam")])
+ )
+ XCTAssertEqual(state.suggestions.count, 1)
+ XCTAssertFalse(effects.shouldSelectAll)
+
+ // First escape: revert + close popup + select-all.
+ effects = omnibarReduce(state: &state, event: .escape)
+ XCTAssertEqual(state.buffer, "https://example.com/")
+ XCTAssertFalse(state.isUserEditing)
+ XCTAssertTrue(state.suggestions.isEmpty)
+ XCTAssertTrue(effects.shouldSelectAll)
+ XCTAssertFalse(effects.shouldBlurToWebView)
+
+ // Second escape: blur (since we're not editing and popup is closed).
+ effects = omnibarReduce(state: &state, event: .escape)
+ XCTAssertTrue(effects.shouldBlurToWebView)
+ }
+
+ func testPanelURLChangeDoesNotClobberUserBufferWhileEditing() throws {
+ var state = OmnibarState()
+ _ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://a.test/"))
+ _ = omnibarReduce(state: &state, event: .bufferChanged("hello"))
+ XCTAssertTrue(state.isUserEditing)
+
+ _ = omnibarReduce(state: &state, event: .panelURLChanged(currentURLString: "https://b.test/"))
+ XCTAssertEqual(state.currentURLString, "https://b.test/")
+ XCTAssertEqual(state.buffer, "hello")
+ XCTAssertTrue(state.isUserEditing)
+
+ let effects = omnibarReduce(state: &state, event: .escape)
+ XCTAssertEqual(state.buffer, "https://b.test/")
+ XCTAssertTrue(effects.shouldSelectAll)
+ }
+
+ func testFocusLostRevertsUnlessSuppressed() throws {
+ var state = OmnibarState()
+ _ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://example.com/"))
+ _ = omnibarReduce(state: &state, event: .bufferChanged("typed"))
+ XCTAssertEqual(state.buffer, "typed")
+
+ _ = omnibarReduce(state: &state, event: .focusLostPreserveBuffer(currentURLString: "https://example.com/"))
+ XCTAssertEqual(state.buffer, "typed")
+
+ _ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://example.com/"))
+ _ = omnibarReduce(state: &state, event: .bufferChanged("typed2"))
+ _ = omnibarReduce(state: &state, event: .focusLostRevertBuffer(currentURLString: "https://example.com/"))
+ XCTAssertEqual(state.buffer, "https://example.com/")
+ }
+
+ func testSuggestionsUpdateKeepsSelectionAcrossNonEmptyListRefresh() throws {
+ var state = OmnibarState()
+ _ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://example.com/"))
+ _ = omnibarReduce(state: &state, event: .bufferChanged("go"))
+
+ let base: [OmnibarSuggestion] = [
+ .search(engineName: "Google", query: "go"),
+ .remoteSearchSuggestion("go tutorial"),
+ .remoteSearchSuggestion("go json"),
+ ]
+ _ = omnibarReduce(state: &state, event: .suggestionsUpdated(base))
+ XCTAssertEqual(state.selectedSuggestionIndex, 0)
+
+ _ = omnibarReduce(state: &state, event: .moveSelection(delta: 2))
+ XCTAssertEqual(state.selectedSuggestionIndex, 2)
+
+ // Simulate remote merge update for the same query while popup remains open.
+ let merged: [OmnibarSuggestion] = [
+ .search(engineName: "Google", query: "go"),
+ .remoteSearchSuggestion("go tutorial"),
+ .remoteSearchSuggestion("go json"),
+ .remoteSearchSuggestion("go fmt"),
+ ]
+ _ = omnibarReduce(state: &state, event: .suggestionsUpdated(merged))
+ XCTAssertEqual(state.selectedSuggestionIndex, 2, "Expected selection to remain stable while list stays open")
+ }
+
+ func testSuggestionsReopenResetsSelectionToFirstRow() throws {
+ var state = OmnibarState()
+ _ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://example.com/"))
+ _ = omnibarReduce(state: &state, event: .bufferChanged("go"))
+
+ let rows: [OmnibarSuggestion] = [
+ .search(engineName: "Google", query: "go"),
+ .remoteSearchSuggestion("go tutorial"),
+ ]
+ _ = omnibarReduce(state: &state, event: .suggestionsUpdated(rows))
+ _ = omnibarReduce(state: &state, event: .moveSelection(delta: 1))
+ XCTAssertEqual(state.selectedSuggestionIndex, 1)
+
+ _ = omnibarReduce(state: &state, event: .suggestionsUpdated([]))
+ XCTAssertEqual(state.selectedSuggestionIndex, 0)
+
+ _ = omnibarReduce(state: &state, event: .suggestionsUpdated(rows))
+ XCTAssertEqual(state.selectedSuggestionIndex, 0, "Expected reopened popup to focus first row")
+ }
+
+ func testSuggestionsUpdatePrefersAutocompleteMatchWhenSelectionNotTracked() throws {
+ var state = OmnibarState()
+ _ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://example.com/"))
+ _ = omnibarReduce(state: &state, event: .bufferChanged("gm"))
+
+ let rows: [OmnibarSuggestion] = [
+ .search(engineName: "Google", query: "gm"),
+ .history(url: "https://google.com/", title: "Google"),
+ .history(url: "https://gmail.com/", title: "Gmail"),
+ ]
+ _ = omnibarReduce(state: &state, event: .suggestionsUpdated(rows))
+ XCTAssertEqual(state.selectedSuggestionIndex, 2, "Expected autocomplete candidate to become selected without explicit index state.")
+ XCTAssertEqual(state.selectedSuggestionID, rows[2].id)
+ XCTAssertTrue(omnibarSuggestionSupportsAutocompletion(query: "gm", suggestion: state.suggestions[state.selectedSuggestionIndex]))
+ XCTAssertEqual(state.suggestions[state.selectedSuggestionIndex].completion, "https://gmail.com/")
+ }
+}
+
+
+final class OmnibarRemoteSuggestionMergeTests: XCTestCase {
+ func testMergeRemoteSuggestionsInsertsBelowSearchAndDedupes() {
+ let now = Date()
+ let entries: [BrowserHistoryStore.Entry] = [
+ BrowserHistoryStore.Entry(
+ id: UUID(),
+ url: "https://go.dev/",
+ title: "The Go Programming Language",
+ lastVisited: now,
+ visitCount: 10
+ ),
+ ]
+
+ let merged = buildOmnibarSuggestions(
+ query: "go",
+ engineName: "Google",
+ historyEntries: entries,
+ openTabMatches: [],
+ remoteQueries: ["go tutorial", "go.dev", "go json"],
+ resolvedURL: nil,
+ limit: 8
+ )
+
+ let completions = merged.compactMap { $0.completion }
+ XCTAssertGreaterThanOrEqual(completions.count, 5)
+ XCTAssertEqual(completions[0], "https://go.dev/")
+ XCTAssertEqual(completions[1], "go")
+
+ let remoteCompletions = Array(completions.dropFirst(2))
+ XCTAssertEqual(Set(remoteCompletions), Set(["go tutorial", "go.dev", "go json"]))
+ XCTAssertEqual(remoteCompletions.count, 3)
+ }
+
+ func testStaleRemoteSuggestionsKeptForNearbyEdits() {
+ let stale = staleOmnibarRemoteSuggestionsForDisplay(
+ query: "go t",
+ previousRemoteQuery: "go",
+ previousRemoteSuggestions: ["go tutorial", "go json", "golang tips"],
+ limit: 8
+ )
+
+ XCTAssertEqual(stale, ["go tutorial", "go json", "golang tips"])
+ }
+
+ func testStaleRemoteSuggestionsTrimAndRespectLimit() {
+ let stale = staleOmnibarRemoteSuggestionsForDisplay(
+ query: "gooo",
+ previousRemoteQuery: "goo",
+ previousRemoteSuggestions: [" go tutorial ", "", "go json", " ", "go fmt"],
+ limit: 2
+ )
+
+ XCTAssertEqual(stale, ["go tutorial", "go json"])
+ }
+
+ func testStaleRemoteSuggestionsDroppedForUnrelatedQuery() {
+ let stale = staleOmnibarRemoteSuggestionsForDisplay(
+ query: "python",
+ previousRemoteQuery: "go",
+ previousRemoteSuggestions: ["go tutorial", "go json"],
+ limit: 8
+ )
+
+ XCTAssertTrue(stale.isEmpty)
+ }
+}
+
+
+final class OmnibarSuggestionRankingTests: XCTestCase {
+ private var fixedNow: Date {
+ Date(timeIntervalSinceReferenceDate: 10_000_000)
+ }
+
+ func testSingleCharacterQueryPromotesAutocompletionMatchToFirstRow() {
+ let entries: [BrowserHistoryStore.Entry] = [
+ .init(
+ id: UUID(),
+ url: "https://news.ycombinator.com/",
+ title: "News.YC",
+ lastVisited: fixedNow,
+ visitCount: 12,
+ typedCount: 1,
+ lastTypedAt: fixedNow
+ ),
+ .init(
+ id: UUID(),
+ url: "https://www.google.com/",
+ title: "Google",
+ lastVisited: fixedNow - 200,
+ visitCount: 8,
+ typedCount: 2,
+ lastTypedAt: fixedNow - 200
+ ),
+ ]
+
+ let results = buildOmnibarSuggestions(
+ query: "n",
+ engineName: "Google",
+ historyEntries: entries,
+ openTabMatches: [],
+ remoteQueries: ["search google for n", "news"],
+ resolvedURL: nil,
+ limit: 8,
+ now: fixedNow
+ )
+
+ XCTAssertEqual(results.first?.completion, "https://news.ycombinator.com/")
+ XCTAssertNotEqual(results.map(\.completion).first, "n")
+ XCTAssertTrue(results.first.map { omnibarSuggestionSupportsAutocompletion(query: "n", suggestion: $0) } ?? false)
+ }
+
+ func testGmAutocompleteCandidateIsFirstOnExactQueryMatch() {
+ let entries: [BrowserHistoryStore.Entry] = [
+ .init(
+ id: UUID(),
+ url: "https://google.com/",
+ title: "Google",
+ lastVisited: fixedNow,
+ visitCount: 4,
+ typedCount: 1,
+ lastTypedAt: fixedNow
+ ),
+ .init(
+ id: UUID(),
+ url: "https://gmail.com/",
+ title: "Gmail",
+ lastVisited: fixedNow,
+ visitCount: 10,
+ typedCount: 2,
+ lastTypedAt: fixedNow
+ ),
+ ]
+
+ let results = buildOmnibarSuggestions(
+ query: "gm",
+ engineName: "Google",
+ historyEntries: entries,
+ openTabMatches: [],
+ remoteQueries: ["gmail", "gmail.com", "google mail"],
+ resolvedURL: nil,
+ limit: 8,
+ now: fixedNow
+ )
+
+ XCTAssertEqual(results.first?.completion, "https://gmail.com/")
+ XCTAssertTrue(omnibarSuggestionSupportsAutocompletion(query: "gm", suggestion: results[0]))
+
+ let inlineCompletion = omnibarInlineCompletionForDisplay(
+ typedText: "gm",
+ suggestions: results,
+ isFocused: true,
+ selectionRange: NSRange(location: 2, length: 0),
+ hasMarkedText: false
+ )
+ XCTAssertNotNil(inlineCompletion)
+ }
+
+ func testAutocompletionCandidateWinsOverRemoteAndSearchRowsForTwoLetterQuery() {
+ let entries: [BrowserHistoryStore.Entry] = [
+ .init(
+ id: UUID(),
+ url: "https://google.com/",
+ title: "Google",
+ lastVisited: fixedNow,
+ visitCount: 4,
+ typedCount: 1,
+ lastTypedAt: fixedNow
+ ),
+ .init(
+ id: UUID(),
+ url: "https://gmail.com/",
+ title: "Gmail",
+ lastVisited: fixedNow,
+ visitCount: 10,
+ typedCount: 2,
+ lastTypedAt: fixedNow
+ ),
+ ]
+
+ let results = buildOmnibarSuggestions(
+ query: "gm",
+ engineName: "Google",
+ historyEntries: entries,
+ openTabMatches: [
+ .init(
+ tabId: UUID(),
+ panelId: UUID(),
+ url: "https://gmail.com/",
+ title: "Gmail",
+ isKnownOpenTab: true
+ ),
+ ],
+ remoteQueries: ["Search google for gm", "gmail", "gmail.com", "Google mail"],
+ resolvedURL: nil,
+ limit: 8,
+ now: fixedNow
+ )
+
+ XCTAssertTrue(omnibarSuggestionSupportsAutocompletion(query: "gm", suggestion: results[0]))
+ XCTAssertEqual(results.first?.completion, "https://gmail.com/")
+ }
+
+ func testSuggestionSelectionPrefersAutocompletionCandidateAfterSuggestionsUpdate() {
+ let entries: [BrowserHistoryStore.Entry] = [
+ .init(
+ id: UUID(),
+ url: "https://google.com/",
+ title: "Google",
+ lastVisited: fixedNow,
+ visitCount: 4,
+ typedCount: 1,
+ lastTypedAt: fixedNow
+ ),
+ .init(
+ id: UUID(),
+ url: "https://gmail.com/",
+ title: "Gmail",
+ lastVisited: fixedNow,
+ visitCount: 10,
+ typedCount: 2,
+ lastTypedAt: fixedNow
+ ),
+ ]
+
+ let results = buildOmnibarSuggestions(
+ query: "gm",
+ engineName: "Google",
+ historyEntries: entries,
+ openTabMatches: [],
+ remoteQueries: ["Search google for gm", "gmail", "gmail.com"],
+ resolvedURL: nil,
+ limit: 8,
+ now: fixedNow
+ )
+
+ var state = OmnibarState()
+ let _ = omnibarReduce(state: &state, event: .focusGained(currentURLString: ""))
+ let _ = omnibarReduce(state: &state, event: .bufferChanged("gm"))
+ let _ = omnibarReduce(state: &state, event: .suggestionsUpdated(results))
+
+ XCTAssertEqual(state.selectedSuggestionIndex, 0)
+ XCTAssertEqual(state.selectedSuggestionID, results[0].id)
+ XCTAssertTrue(omnibarSuggestionSupportsAutocompletion(query: "gm", suggestion: state.suggestions[0]))
+ }
+
+ func testTwoCharQueryWithRemoteSuggestionsStillPromotesAutocompletionMatch() {
+ let entries: [BrowserHistoryStore.Entry] = [
+ .init(
+ id: UUID(),
+ url: "https://news.ycombinator.com/",
+ title: "News.YC",
+ lastVisited: fixedNow,
+ visitCount: 12,
+ typedCount: 1,
+ lastTypedAt: fixedNow
+ ),
+ .init(
+ id: UUID(),
+ url: "https://www.google.com/",
+ title: "Google",
+ lastVisited: fixedNow - 200,
+ visitCount: 8,
+ typedCount: 2,
+ lastTypedAt: fixedNow - 200
+ ),
+ ]
+
+ let results = buildOmnibarSuggestions(
+ query: "ne",
+ engineName: "Google",
+ historyEntries: entries,
+ openTabMatches: [],
+ remoteQueries: ["netflix", "new york times", "newegg"],
+ resolvedURL: nil,
+ limit: 8,
+ now: fixedNow
+ )
+
+ // The autocompletable history entry (news.ycombinator.com) should be first despite remote results.
+ XCTAssertEqual(results.first?.completion, "https://news.ycombinator.com/")
+ XCTAssertTrue(results.first.map { omnibarSuggestionSupportsAutocompletion(query: "ne", suggestion: $0) } ?? false)
+
+ // Remote suggestions should still appear in the results (two-char queries include them).
+ let remoteCompletions = results.filter {
+ if case .remote = $0.kind { return true }
+ return false
+ }.map(\.completion)
+ XCTAssertFalse(remoteCompletions.isEmpty, "Expected remote suggestions to be present for two-char query")
+ }
+
+ func testGmQueryWithRemoteSuggestionsAndOpenTabPromotesAutocompletionMatch() {
+ let entries: [BrowserHistoryStore.Entry] = [
+ .init(
+ id: UUID(),
+ url: "https://google.com/",
+ title: "Google",
+ lastVisited: fixedNow,
+ visitCount: 4,
+ typedCount: 1,
+ lastTypedAt: fixedNow
+ ),
+ .init(
+ id: UUID(),
+ url: "https://gmail.com/",
+ title: "Gmail",
+ lastVisited: fixedNow,
+ visitCount: 10,
+ typedCount: 2,
+ lastTypedAt: fixedNow
+ ),
+ ]
+
+ let results = buildOmnibarSuggestions(
+ query: "gm",
+ engineName: "Google",
+ historyEntries: entries,
+ openTabMatches: [
+ .init(
+ tabId: UUID(),
+ panelId: UUID(),
+ url: "https://google.com/maps",
+ title: "Google Maps",
+ isKnownOpenTab: true
+ ),
+ ],
+ remoteQueries: ["gmail login", "gm stock price", "gmail.com"],
+ resolvedURL: nil,
+ limit: 8,
+ now: fixedNow
+ )
+
+ // Gmail should be first (autocompletable + typed history).
+ XCTAssertEqual(results.first?.completion, "https://gmail.com/")
+ XCTAssertTrue(omnibarSuggestionSupportsAutocompletion(query: "gm", suggestion: results[0]))
+
+ // Verify remote suggestions are present alongside history/tab matches.
+ let remoteCompletions = results.filter {
+ if case .remote = $0.kind { return true }
+ return false
+ }.map(\.completion)
+ XCTAssertFalse(remoteCompletions.isEmpty, "Expected remote suggestions in results")
+ let hasSearch = results.contains {
+ if case .search = $0.kind { return true }
+ return false
+ }
+ XCTAssertTrue(hasSearch, "Expected search row in results")
+ }
+
+ func testHistorySuggestionDisplaysTitleAndUrlOnSingleLine() {
+ let row = OmnibarSuggestion.history(
+ url: "https://www.example.com/path?q=1",
+ title: "Example Domain"
+ )
+ XCTAssertEqual(row.listText, "Example Domain — example.com/path?q=1")
+ XCTAssertFalse(row.listText.contains("\n"))
+ }
+
+ func testPublishedBufferTextUsesTypedPrefixWhenInlineSuffixIsSelected() {
+ let inline = OmnibarInlineCompletion(
+ typedText: "l",
+ displayText: "localhost:3000",
+ acceptedText: "https://localhost:3000/"
+ )
+
+ let published = omnibarPublishedBufferTextForFieldChange(
+ fieldValue: inline.displayText,
+ inlineCompletion: inline,
+ selectionRange: inline.suffixRange,
+ hasMarkedText: false
+ )
+
+ XCTAssertEqual(published, "l")
+ }
+
+ func testPublishedBufferTextKeepsUserTypedValueWhenDisplayDiffersFromInlineText() {
+ let inline = OmnibarInlineCompletion(
+ typedText: "l",
+ displayText: "localhost:3000",
+ acceptedText: "https://localhost:3000/"
+ )
+
+ let published = omnibarPublishedBufferTextForFieldChange(
+ fieldValue: "la",
+ inlineCompletion: inline,
+ selectionRange: NSRange(location: 2, length: 0),
+ hasMarkedText: false
+ )
+
+ XCTAssertEqual(published, "la")
+ }
+
+ func testInlineCompletionRenderIgnoresStaleTypedPrefixMismatch() {
+ let staleInline = OmnibarInlineCompletion(
+ typedText: "g",
+ displayText: "github.com",
+ acceptedText: "https://github.com/"
+ )
+
+ let active = omnibarInlineCompletionIfBufferMatchesTypedPrefix(
+ bufferText: "l",
+ inlineCompletion: staleInline
+ )
+
+ XCTAssertNil(active)
+ }
+
+ func testInlineCompletionRenderKeepsMatchingTypedPrefix() {
+ let inline = OmnibarInlineCompletion(
+ typedText: "l",
+ displayText: "localhost:3000",
+ acceptedText: "https://localhost:3000/"
+ )
+
+ let active = omnibarInlineCompletionIfBufferMatchesTypedPrefix(
+ bufferText: "l",
+ inlineCompletion: inline
+ )
+
+ XCTAssertEqual(active, inline)
+ }
+
+ func testInlineCompletionSkipsTitleMatchWhoseURLDoesNotStartWithTypedText() {
+ // History entry: visited google.com/search?q=localhost:3000 with title
+ // "localhost:3000 - Google Search". Typing "l" should NOT inline-complete
+ // to "google.com/..." because that replaces the typed "l" with "g".
+ let suggestions: [OmnibarSuggestion] = [
+ .history(
+ url: "https://www.google.com/search?q=localhost:3000",
+ title: "localhost:3000 - Google Search"
+ ),
+ ]
+
+ let result = omnibarInlineCompletionForDisplay(
+ typedText: "l",
+ suggestions: suggestions,
+ isFocused: true,
+ selectionRange: NSRange(location: 1, length: 0),
+ hasMarkedText: false
+ )
+
+ XCTAssertNil(result, "Should not inline-complete when display text does not start with typed prefix")
+ }
+}
diff --git a/cmuxTests/ShortcutAndCommandPaletteTests.swift b/cmuxTests/ShortcutAndCommandPaletteTests.swift
new file mode 100644
index 00000000..cc5b0c01
--- /dev/null
+++ b/cmuxTests/ShortcutAndCommandPaletteTests.swift
@@ -0,0 +1,965 @@
+import XCTest
+import AppKit
+import SwiftUI
+import UniformTypeIdentifiers
+import WebKit
+import ObjectiveC.runtime
+import Bonsplit
+import UserNotifications
+
+#if canImport(cmux_DEV)
+@testable import cmux_DEV
+#elseif canImport(cmux)
+@testable import cmux
+#endif
+
+final class SplitShortcutTransientFocusGuardTests: XCTestCase {
+ func testSuppressesWhenFirstResponderFallsBackAndHostedViewIsTiny() {
+ XCTAssertTrue(
+ shouldSuppressSplitShortcutForTransientTerminalFocusInputs(
+ firstResponderIsWindow: true,
+ hostedSize: CGSize(width: 79, height: 0),
+ hostedHiddenInHierarchy: false,
+ hostedAttachedToWindow: true
+ )
+ )
+ }
+
+ func testSuppressesWhenFirstResponderFallsBackAndHostedViewIsDetached() {
+ XCTAssertTrue(
+ shouldSuppressSplitShortcutForTransientTerminalFocusInputs(
+ firstResponderIsWindow: true,
+ hostedSize: CGSize(width: 1051.5, height: 1207),
+ hostedHiddenInHierarchy: false,
+ hostedAttachedToWindow: false
+ )
+ )
+ }
+
+ func testAllowsWhenFirstResponderFallsBackButGeometryIsHealthy() {
+ XCTAssertFalse(
+ shouldSuppressSplitShortcutForTransientTerminalFocusInputs(
+ firstResponderIsWindow: true,
+ hostedSize: CGSize(width: 1051.5, height: 1207),
+ hostedHiddenInHierarchy: false,
+ hostedAttachedToWindow: true
+ )
+ )
+ }
+
+ func testAllowsWhenFirstResponderIsTerminalEvenIfViewIsTiny() {
+ XCTAssertFalse(
+ shouldSuppressSplitShortcutForTransientTerminalFocusInputs(
+ firstResponderIsWindow: false,
+ hostedSize: CGSize(width: 79, height: 0),
+ hostedHiddenInHierarchy: false,
+ hostedAttachedToWindow: true
+ )
+ )
+ }
+}
+
+
+final class FullScreenShortcutTests: XCTestCase {
+ func testMatchesCommandControlF() {
+ XCTAssertTrue(
+ shouldToggleMainWindowFullScreenForCommandControlFShortcut(
+ flags: [.command, .control],
+ chars: "f",
+ keyCode: 3
+ )
+ )
+ }
+
+ func testMatchesCommandControlFFromKeyCodeWhenCharsAreUnavailable() {
+ XCTAssertTrue(
+ shouldToggleMainWindowFullScreenForCommandControlFShortcut(
+ flags: [.command, .control],
+ chars: "",
+ keyCode: 3,
+ layoutCharacterProvider: { _, _ in nil }
+ )
+ )
+ }
+
+ func testDoesNotFallbackToANSIWhenLayoutTranslationReturnsNonFCharacter() {
+ XCTAssertFalse(
+ shouldToggleMainWindowFullScreenForCommandControlFShortcut(
+ flags: [.command, .control],
+ chars: "",
+ keyCode: 3,
+ layoutCharacterProvider: { _, _ in "u" }
+ )
+ )
+ }
+
+ func testMatchesCommandControlFWhenCommandAwareLayoutTranslationProvidesF() {
+ XCTAssertTrue(
+ shouldToggleMainWindowFullScreenForCommandControlFShortcut(
+ flags: [.command, .control],
+ chars: "",
+ keyCode: 3,
+ layoutCharacterProvider: { _, modifierFlags in
+ modifierFlags.contains(.command) ? "f" : "u"
+ }
+ )
+ )
+ }
+
+ func testMatchesCommandControlFWhenCharsAreControlSequence() {
+ XCTAssertTrue(
+ shouldToggleMainWindowFullScreenForCommandControlFShortcut(
+ flags: [.command, .control],
+ chars: "\u{06}",
+ keyCode: 3,
+ layoutCharacterProvider: { _, _ in nil }
+ )
+ )
+ }
+
+ func testRejectsPhysicalFWhenCharacterRepresentsDifferentLayoutKey() {
+ XCTAssertFalse(
+ shouldToggleMainWindowFullScreenForCommandControlFShortcut(
+ flags: [.command, .control],
+ chars: "u",
+ keyCode: 3
+ )
+ )
+ }
+
+ func testIgnoresCapsLockForCommandControlF() {
+ XCTAssertTrue(
+ shouldToggleMainWindowFullScreenForCommandControlFShortcut(
+ flags: [.command, .control, .capsLock],
+ chars: "f",
+ keyCode: 3
+ )
+ )
+ }
+
+ func testRejectsWhenControlIsMissing() {
+ XCTAssertFalse(
+ shouldToggleMainWindowFullScreenForCommandControlFShortcut(
+ flags: [.command],
+ chars: "f",
+ keyCode: 3
+ )
+ )
+ }
+
+ func testRejectsAdditionalModifiers() {
+ XCTAssertFalse(
+ shouldToggleMainWindowFullScreenForCommandControlFShortcut(
+ flags: [.command, .control, .shift],
+ chars: "f",
+ keyCode: 3
+ )
+ )
+ XCTAssertFalse(
+ shouldToggleMainWindowFullScreenForCommandControlFShortcut(
+ flags: [.command, .control, .option],
+ chars: "f",
+ keyCode: 3
+ )
+ )
+ }
+
+ func testRejectsWhenCommandIsMissing() {
+ XCTAssertFalse(
+ shouldToggleMainWindowFullScreenForCommandControlFShortcut(
+ flags: [.control],
+ chars: "f",
+ keyCode: 3
+ )
+ )
+ }
+
+ func testRejectsNonFKey() {
+ XCTAssertFalse(
+ shouldToggleMainWindowFullScreenForCommandControlFShortcut(
+ flags: [.command, .control],
+ chars: "r",
+ keyCode: 15
+ )
+ )
+ }
+}
+
+
+final class CommandPaletteKeyboardNavigationTests: XCTestCase {
+ func testArrowKeysMoveSelectionWithoutModifiers() {
+ XCTAssertEqual(
+ commandPaletteSelectionDeltaForKeyboardNavigation(
+ flags: [],
+ chars: "",
+ keyCode: 125
+ ),
+ 1
+ )
+ XCTAssertEqual(
+ commandPaletteSelectionDeltaForKeyboardNavigation(
+ flags: [],
+ chars: "",
+ keyCode: 126
+ ),
+ -1
+ )
+ XCTAssertNil(
+ commandPaletteSelectionDeltaForKeyboardNavigation(
+ flags: [.shift],
+ chars: "",
+ keyCode: 125
+ )
+ )
+ }
+
+ func testControlLetterNavigationSupportsPrintableAndControlChars() {
+ XCTAssertEqual(
+ commandPaletteSelectionDeltaForKeyboardNavigation(
+ flags: [.control],
+ chars: "n",
+ keyCode: 45
+ ),
+ 1
+ )
+ XCTAssertEqual(
+ commandPaletteSelectionDeltaForKeyboardNavigation(
+ flags: [.control],
+ chars: "\u{0e}",
+ keyCode: 45
+ ),
+ 1
+ )
+ XCTAssertEqual(
+ commandPaletteSelectionDeltaForKeyboardNavigation(
+ flags: [.control],
+ chars: "p",
+ keyCode: 35
+ ),
+ -1
+ )
+ XCTAssertEqual(
+ commandPaletteSelectionDeltaForKeyboardNavigation(
+ flags: [.control],
+ chars: "\u{10}",
+ keyCode: 35
+ ),
+ -1
+ )
+ XCTAssertEqual(
+ commandPaletteSelectionDeltaForKeyboardNavigation(
+ flags: [.control],
+ chars: "j",
+ keyCode: 38
+ ),
+ 1
+ )
+ XCTAssertEqual(
+ commandPaletteSelectionDeltaForKeyboardNavigation(
+ flags: [.control],
+ chars: "\u{0a}",
+ keyCode: 38
+ ),
+ 1
+ )
+ XCTAssertEqual(
+ commandPaletteSelectionDeltaForKeyboardNavigation(
+ flags: [.control],
+ chars: "k",
+ keyCode: 40
+ ),
+ -1
+ )
+ XCTAssertEqual(
+ commandPaletteSelectionDeltaForKeyboardNavigation(
+ flags: [.control],
+ chars: "\u{0b}",
+ keyCode: 40
+ ),
+ -1
+ )
+ }
+
+ func testIgnoresUnsupportedModifiersAndKeys() {
+ XCTAssertNil(
+ commandPaletteSelectionDeltaForKeyboardNavigation(
+ flags: [.command],
+ chars: "n",
+ keyCode: 45
+ )
+ )
+ XCTAssertNil(
+ commandPaletteSelectionDeltaForKeyboardNavigation(
+ flags: [.control, .shift],
+ chars: "n",
+ keyCode: 45
+ )
+ )
+ XCTAssertNil(
+ commandPaletteSelectionDeltaForKeyboardNavigation(
+ flags: [.control],
+ chars: "x",
+ keyCode: 7
+ )
+ )
+ }
+}
+
+
+final class CommandPaletteOpenShortcutConsumptionTests: XCTestCase {
+ func testDoesNotConsumeWhenPaletteIsNotVisible() {
+ XCTAssertFalse(
+ shouldConsumeShortcutWhileCommandPaletteVisible(
+ isCommandPaletteVisible: false,
+ normalizedFlags: [.command],
+ chars: "n",
+ keyCode: 45
+ )
+ )
+ }
+
+ func testConsumesAppCommandShortcutsWhenPaletteIsVisible() {
+ XCTAssertTrue(
+ shouldConsumeShortcutWhileCommandPaletteVisible(
+ isCommandPaletteVisible: true,
+ normalizedFlags: [.command],
+ chars: "n",
+ keyCode: 45
+ )
+ )
+ XCTAssertTrue(
+ shouldConsumeShortcutWhileCommandPaletteVisible(
+ isCommandPaletteVisible: true,
+ normalizedFlags: [.command],
+ chars: "t",
+ keyCode: 17
+ )
+ )
+ XCTAssertTrue(
+ shouldConsumeShortcutWhileCommandPaletteVisible(
+ isCommandPaletteVisible: true,
+ normalizedFlags: [.command, .shift],
+ chars: ",",
+ keyCode: 43
+ )
+ )
+ }
+
+ func testAllowsClipboardAndUndoShortcutsForPaletteTextEditing() {
+ XCTAssertFalse(
+ shouldConsumeShortcutWhileCommandPaletteVisible(
+ isCommandPaletteVisible: true,
+ normalizedFlags: [.command],
+ chars: "v",
+ keyCode: 9
+ )
+ )
+ XCTAssertFalse(
+ shouldConsumeShortcutWhileCommandPaletteVisible(
+ isCommandPaletteVisible: true,
+ normalizedFlags: [.command],
+ chars: "z",
+ keyCode: 6
+ )
+ )
+ XCTAssertFalse(
+ shouldConsumeShortcutWhileCommandPaletteVisible(
+ isCommandPaletteVisible: true,
+ normalizedFlags: [.command, .shift],
+ chars: "z",
+ keyCode: 6
+ )
+ )
+ }
+
+ func testAllowsArrowAndDeleteEditingCommandsForPaletteTextEditing() {
+ XCTAssertFalse(
+ shouldConsumeShortcutWhileCommandPaletteVisible(
+ isCommandPaletteVisible: true,
+ normalizedFlags: [.command],
+ chars: "",
+ keyCode: 123
+ )
+ )
+ XCTAssertFalse(
+ shouldConsumeShortcutWhileCommandPaletteVisible(
+ isCommandPaletteVisible: true,
+ normalizedFlags: [.command],
+ chars: "",
+ keyCode: 51
+ )
+ )
+ }
+
+ func testConsumesEscapeWhenPaletteIsVisible() {
+ XCTAssertTrue(
+ shouldConsumeShortcutWhileCommandPaletteVisible(
+ isCommandPaletteVisible: true,
+ normalizedFlags: [],
+ chars: "",
+ keyCode: 53
+ )
+ )
+ }
+}
+
+
+final class CommandPaletteRestoreFocusStateMachineTests: XCTestCase {
+ func testRestoresBrowserAddressBarWhenPaletteOpenedFromFocusedAddressBar() {
+ let panelId = UUID()
+ XCTAssertTrue(
+ ContentView.shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss(
+ focusedPanelIsBrowser: true,
+ focusedBrowserAddressBarPanelId: panelId,
+ focusedPanelId: panelId
+ )
+ )
+ }
+
+ func testDoesNotRestoreBrowserAddressBarWhenFocusedPanelIsNotBrowser() {
+ let panelId = UUID()
+ XCTAssertFalse(
+ ContentView.shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss(
+ focusedPanelIsBrowser: false,
+ focusedBrowserAddressBarPanelId: panelId,
+ focusedPanelId: panelId
+ )
+ )
+ }
+
+ func testDoesNotRestoreBrowserAddressBarWhenAnotherPanelHadAddressBarFocus() {
+ XCTAssertFalse(
+ ContentView.shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss(
+ focusedPanelIsBrowser: true,
+ focusedBrowserAddressBarPanelId: UUID(),
+ focusedPanelId: UUID()
+ )
+ )
+ }
+}
+
+
+final class CommandPaletteRenameSelectionSettingsTests: XCTestCase {
+ private let suiteName = "cmux.tests.commandPaletteRenameSelection.\(UUID().uuidString)"
+
+ private func makeDefaults() -> UserDefaults {
+ let defaults = UserDefaults(suiteName: suiteName)!
+ defaults.removePersistentDomain(forName: suiteName)
+ return defaults
+ }
+
+ func testDefaultsToSelectAllWhenUnset() {
+ let defaults = makeDefaults()
+ XCTAssertTrue(CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled(defaults: defaults))
+ }
+
+ func testReturnsFalseWhenStoredFalse() {
+ let defaults = makeDefaults()
+ defaults.set(false, forKey: CommandPaletteRenameSelectionSettings.selectAllOnFocusKey)
+ XCTAssertFalse(CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled(defaults: defaults))
+ }
+
+ func testReturnsTrueWhenStoredTrue() {
+ let defaults = makeDefaults()
+ defaults.set(true, forKey: CommandPaletteRenameSelectionSettings.selectAllOnFocusKey)
+ XCTAssertTrue(CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled(defaults: defaults))
+ }
+}
+
+
+final class CommandPaletteSelectionScrollBehaviorTests: XCTestCase {
+ func testFirstEntryPinsToTopAnchor() {
+ let anchor = ContentView.commandPaletteScrollPositionAnchor(
+ selectedIndex: 0,
+ resultCount: 20
+ )
+ XCTAssertEqual(anchor, UnitPoint.top)
+ }
+
+ func testLastEntryPinsToBottomAnchor() {
+ let anchor = ContentView.commandPaletteScrollPositionAnchor(
+ selectedIndex: 19,
+ resultCount: 20
+ )
+ XCTAssertEqual(anchor, UnitPoint.bottom)
+ }
+
+ func testMiddleEntryUsesNilAnchorForMinimalScroll() {
+ let anchor = ContentView.commandPaletteScrollPositionAnchor(
+ selectedIndex: 6,
+ resultCount: 20
+ )
+ XCTAssertNil(anchor)
+ }
+
+ func testEmptyResultsProduceNoAnchor() {
+ let anchor = ContentView.commandPaletteScrollPositionAnchor(
+ selectedIndex: 0,
+ resultCount: 0
+ )
+ XCTAssertNil(anchor)
+ }
+}
+
+
+final class ShortcutHintModifierPolicyTests: XCTestCase {
+ func testShortcutHintRequiresEnabledCommandOnlyModifier() {
+ withDefaultsSuite { defaults in
+ defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey)
+
+ XCTAssertTrue(ShortcutHintModifierPolicy.shouldShowHints(for: [.command], defaults: defaults))
+ XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control], defaults: defaults))
+ XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [], defaults: defaults))
+ XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.command, .shift], defaults: defaults))
+ XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control, .shift], defaults: defaults))
+ XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.command, .option], defaults: defaults))
+ XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control, .option], defaults: defaults))
+ XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.command, .control], defaults: defaults))
+ }
+ }
+
+ func testCommandHintCanBeDisabledInSettings() {
+ withDefaultsSuite { defaults in
+ defaults.set(false, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey)
+
+ XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.command], defaults: defaults))
+ XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control], defaults: defaults))
+ }
+ }
+
+ func testCommandHintDefaultsToEnabledWhenSettingMissing() {
+ withDefaultsSuite { defaults in
+ defaults.removeObject(forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey)
+
+ XCTAssertTrue(ShortcutHintModifierPolicy.shouldShowHints(for: [.command], defaults: defaults))
+ XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control], defaults: defaults))
+ }
+ }
+
+ func testShortcutHintUsesIntentionalHoldDelay() {
+ XCTAssertEqual(ShortcutHintModifierPolicy.intentionalHoldDelay, 0.30, accuracy: 0.001)
+ }
+
+ func testCurrentWindowRequiresHostWindowToBeKeyAndMatchEventWindow() {
+ XCTAssertTrue(
+ ShortcutHintModifierPolicy.isCurrentWindow(
+ hostWindowNumber: 42,
+ hostWindowIsKey: true,
+ eventWindowNumber: 42,
+ keyWindowNumber: 42
+ )
+ )
+
+ XCTAssertFalse(
+ ShortcutHintModifierPolicy.isCurrentWindow(
+ hostWindowNumber: 42,
+ hostWindowIsKey: true,
+ eventWindowNumber: 7,
+ keyWindowNumber: 42
+ )
+ )
+
+ XCTAssertFalse(
+ ShortcutHintModifierPolicy.isCurrentWindow(
+ hostWindowNumber: 42,
+ hostWindowIsKey: false,
+ eventWindowNumber: 42,
+ keyWindowNumber: 42
+ )
+ )
+ }
+
+ func testWindowScopedShortcutHintsUseKeyWindowWhenNoEventWindowIsAvailable() {
+ withDefaultsSuite { defaults in
+ defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey)
+
+ XCTAssertTrue(
+ ShortcutHintModifierPolicy.shouldShowHints(
+ for: [.command],
+ hostWindowNumber: 42,
+ hostWindowIsKey: true,
+ eventWindowNumber: nil,
+ keyWindowNumber: 42,
+ defaults: defaults
+ )
+ )
+
+ XCTAssertFalse(
+ ShortcutHintModifierPolicy.shouldShowHints(
+ for: [.command],
+ hostWindowNumber: 42,
+ hostWindowIsKey: true,
+ eventWindowNumber: nil,
+ keyWindowNumber: 7,
+ defaults: defaults
+ )
+ )
+
+ XCTAssertTrue(
+ ShortcutHintModifierPolicy.shouldShowHints(
+ for: [.command],
+ hostWindowNumber: 42,
+ hostWindowIsKey: true,
+ eventWindowNumber: nil,
+ keyWindowNumber: 42,
+ defaults: defaults
+ )
+ )
+
+ XCTAssertFalse(
+ ShortcutHintModifierPolicy.shouldShowHints(
+ for: [.control],
+ hostWindowNumber: 42,
+ hostWindowIsKey: true,
+ eventWindowNumber: nil,
+ keyWindowNumber: 42,
+ defaults: defaults
+ )
+ )
+ }
+ }
+
+ private func withDefaultsSuite(_ body: (UserDefaults) -> Void) {
+ let suiteName = "ShortcutHintModifierPolicyTests-\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create defaults suite")
+ return
+ }
+
+ defaults.removePersistentDomain(forName: suiteName)
+ body(defaults)
+ defaults.removePersistentDomain(forName: suiteName)
+ }
+}
+
+
+final class ShortcutHintDebugSettingsTests: XCTestCase {
+ func testClampKeepsValuesWithinSupportedRange() {
+ XCTAssertEqual(ShortcutHintDebugSettings.clamped(0.0), 0.0)
+ XCTAssertEqual(ShortcutHintDebugSettings.clamped(4.0), 4.0)
+ XCTAssertEqual(ShortcutHintDebugSettings.clamped(-100.0), ShortcutHintDebugSettings.offsetRange.lowerBound)
+ XCTAssertEqual(ShortcutHintDebugSettings.clamped(100.0), ShortcutHintDebugSettings.offsetRange.upperBound)
+ }
+
+ func testDefaultOffsetsMatchCurrentBadgePlacements() {
+ XCTAssertEqual(ShortcutHintDebugSettings.defaultSidebarHintX, 0.0)
+ XCTAssertEqual(ShortcutHintDebugSettings.defaultSidebarHintY, 0.0)
+ XCTAssertEqual(ShortcutHintDebugSettings.defaultTitlebarHintX, 4.0)
+ XCTAssertEqual(ShortcutHintDebugSettings.defaultTitlebarHintY, 0.0)
+ XCTAssertEqual(ShortcutHintDebugSettings.defaultPaneHintX, 0.0)
+ XCTAssertEqual(ShortcutHintDebugSettings.defaultPaneHintY, 0.0)
+ XCTAssertFalse(ShortcutHintDebugSettings.defaultAlwaysShowHints)
+ XCTAssertTrue(ShortcutHintDebugSettings.defaultShowHintsOnCommandHold)
+ }
+
+ func testShowHintsOnCommandHoldSettingRespectsStoredValue() {
+ let suiteName = "ShortcutHintDebugSettingsTests-\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create defaults suite")
+ return
+ }
+
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ defaults.removeObject(forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey)
+ XCTAssertTrue(ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults))
+
+ defaults.set(false, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey)
+ XCTAssertFalse(ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults))
+
+ defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey)
+ XCTAssertTrue(ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults))
+ }
+
+ func testResetVisibilityDefaultsRestoresAlwaysShowAndCommandHoldFlags() {
+ let suiteName = "ShortcutHintDebugSettingsTests-\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create defaults suite")
+ return
+ }
+
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ defaults.set(true, forKey: ShortcutHintDebugSettings.alwaysShowHintsKey)
+ defaults.set(false, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey)
+
+ ShortcutHintDebugSettings.resetVisibilityDefaults(defaults: defaults)
+
+ XCTAssertEqual(
+ defaults.object(forKey: ShortcutHintDebugSettings.alwaysShowHintsKey) as? Bool,
+ ShortcutHintDebugSettings.defaultAlwaysShowHints
+ )
+ XCTAssertEqual(
+ defaults.object(forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) as? Bool,
+ ShortcutHintDebugSettings.defaultShowHintsOnCommandHold
+ )
+ }
+}
+
+
+final class DevBuildBannerDebugSettingsTests: XCTestCase {
+ func testShowSidebarBannerDefaultsToVisible() {
+ let suiteName = "DevBuildBannerDebugSettingsTests.Default.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ defaults.removeObject(forKey: DevBuildBannerDebugSettings.sidebarBannerVisibleKey)
+ XCTAssertTrue(DevBuildBannerDebugSettings.showSidebarBanner(defaults: defaults))
+ }
+
+ func testShowSidebarBannerRespectsStoredValue() {
+ let suiteName = "DevBuildBannerDebugSettingsTests.Stored.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ defaults.set(false, forKey: DevBuildBannerDebugSettings.sidebarBannerVisibleKey)
+ XCTAssertFalse(DevBuildBannerDebugSettings.showSidebarBanner(defaults: defaults))
+
+ defaults.set(true, forKey: DevBuildBannerDebugSettings.sidebarBannerVisibleKey)
+ XCTAssertTrue(DevBuildBannerDebugSettings.showSidebarBanner(defaults: defaults))
+ }
+}
+
+
+final class ShortcutHintLanePlannerTests: XCTestCase {
+ func testAssignLanesKeepsSeparatedIntervalsOnSingleLane() {
+ let intervals: [ClosedRange] = [0...20, 28...40, 48...64]
+ XCTAssertEqual(ShortcutHintLanePlanner.assignLanes(for: intervals, minSpacing: 4), [0, 0, 0])
+ }
+
+ func testAssignLanesStacksOverlappingIntervalsIntoAdditionalLanes() {
+ let intervals: [ClosedRange] = [0...20, 18...34, 22...38, 40...56]
+ XCTAssertEqual(ShortcutHintLanePlanner.assignLanes(for: intervals, minSpacing: 4), [0, 1, 2, 0])
+ }
+}
+
+
+final class ShortcutHintHorizontalPlannerTests: XCTestCase {
+ func testAssignRightEdgesResolvesOverlapWithMinimumSpacing() {
+ let intervals: [ClosedRange] = [0...20, 18...34, 30...46]
+ let rightEdges = ShortcutHintHorizontalPlanner.assignRightEdges(for: intervals, minSpacing: 6)
+
+ XCTAssertEqual(rightEdges.count, intervals.count)
+
+ let adjustedIntervals = zip(intervals, rightEdges).map { interval, rightEdge in
+ let width = interval.upperBound - interval.lowerBound
+ return (rightEdge - width)...rightEdge
+ }
+
+ XCTAssertGreaterThanOrEqual(adjustedIntervals[1].lowerBound - adjustedIntervals[0].upperBound, 6)
+ XCTAssertGreaterThanOrEqual(adjustedIntervals[2].lowerBound - adjustedIntervals[1].upperBound, 6)
+ }
+
+ func testAssignRightEdgesKeepsAlreadySeparatedIntervalsInPlace() {
+ let intervals: [ClosedRange] = [0...12, 20...32, 40...52]
+ let rightEdges = ShortcutHintHorizontalPlanner.assignRightEdges(for: intervals, minSpacing: 4)
+ XCTAssertEqual(rightEdges, [12, 32, 52])
+ }
+}
+
+
+final class LastSurfaceCloseShortcutSettingsTests: XCTestCase {
+ func testDefaultClosesWorkspace() {
+ let suiteName = "LastSurfaceCloseShortcutSettingsTests.Default.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ XCTAssertTrue(LastSurfaceCloseShortcutSettings.closesWorkspace(defaults: defaults))
+ }
+
+ func testStoredTrueClosesWorkspace() {
+ let suiteName = "LastSurfaceCloseShortcutSettingsTests.Enabled.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ defaults.set(true, forKey: LastSurfaceCloseShortcutSettings.key)
+ XCTAssertTrue(LastSurfaceCloseShortcutSettings.closesWorkspace(defaults: defaults))
+ }
+
+ func testStoredFalseKeepsWorkspaceOpen() {
+ let suiteName = "LastSurfaceCloseShortcutSettingsTests.Disabled.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ defaults.set(false, forKey: LastSurfaceCloseShortcutSettings.key)
+ XCTAssertFalse(LastSurfaceCloseShortcutSettings.closesWorkspace(defaults: defaults))
+ }
+}
+
+
+final class AppearanceSettingsTests: XCTestCase {
+ func testResolvedModeDefaultsToSystemWhenUnset() {
+ let suiteName = "AppearanceSettingsTests.Default.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ defaults.removeObject(forKey: AppearanceSettings.appearanceModeKey)
+
+ let resolved = AppearanceSettings.resolvedMode(defaults: defaults)
+ XCTAssertEqual(resolved, .system)
+ XCTAssertEqual(defaults.string(forKey: AppearanceSettings.appearanceModeKey), AppearanceMode.system.rawValue)
+ }
+}
+
+
+final class QuitWarningSettingsTests: XCTestCase {
+ func testDefaultWarnBeforeQuitIsEnabledWhenUnset() {
+ let suiteName = "QuitWarningSettingsTests.Default.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ defaults.removeObject(forKey: QuitWarningSettings.warnBeforeQuitKey)
+
+ XCTAssertTrue(QuitWarningSettings.isEnabled(defaults: defaults))
+ }
+
+ func testStoredPreferenceOverridesDefault() {
+ let suiteName = "QuitWarningSettingsTests.Stored.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ defaults.set(false, forKey: QuitWarningSettings.warnBeforeQuitKey)
+ XCTAssertFalse(QuitWarningSettings.isEnabled(defaults: defaults))
+
+ defaults.set(true, forKey: QuitWarningSettings.warnBeforeQuitKey)
+ XCTAssertTrue(QuitWarningSettings.isEnabled(defaults: defaults))
+ }
+}
+
+
+final class UpdateChannelSettingsTests: XCTestCase {
+ func testResolvedFeedFallsBackWhenInfoFeedMissing() {
+ let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: nil)
+ XCTAssertEqual(resolved.url, UpdateFeedResolver.fallbackFeedURL)
+ XCTAssertFalse(resolved.isNightly)
+ XCTAssertTrue(resolved.usedFallback)
+ }
+
+ func testResolvedFeedFallsBackWhenInfoFeedEmpty() {
+ let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: "")
+ XCTAssertEqual(resolved.url, UpdateFeedResolver.fallbackFeedURL)
+ XCTAssertFalse(resolved.isNightly)
+ XCTAssertTrue(resolved.usedFallback)
+ }
+
+ func testResolvedFeedUsesInfoFeedForStableChannel() {
+ let infoFeed = "https://example.com/custom/appcast.xml"
+ let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: infoFeed)
+ XCTAssertEqual(resolved.url, infoFeed)
+ XCTAssertFalse(resolved.isNightly)
+ XCTAssertFalse(resolved.usedFallback)
+ }
+
+ func testResolvedFeedDetectsNightlyFromInfoFeedURL() {
+ let resolved = UpdateFeedResolver.resolvedFeedURLString(
+ infoFeedURL: "https://example.com/nightly/appcast.xml"
+ )
+ XCTAssertEqual(resolved.url, "https://example.com/nightly/appcast.xml")
+ XCTAssertTrue(resolved.isNightly)
+ XCTAssertFalse(resolved.usedFallback)
+ }
+}
+
+
+final class UpdateSettingsTests: XCTestCase {
+ func testApplyEnablesAutomaticChecksAndDailySchedule() {
+ let defaults = makeDefaults()
+ UpdateSettings.apply(to: defaults)
+
+ XCTAssertTrue(defaults.bool(forKey: UpdateSettings.automaticChecksKey))
+ XCTAssertEqual(defaults.double(forKey: UpdateSettings.scheduledCheckIntervalKey), UpdateSettings.scheduledCheckInterval)
+ XCTAssertFalse(defaults.bool(forKey: UpdateSettings.automaticallyUpdateKey))
+ XCTAssertFalse(defaults.bool(forKey: UpdateSettings.sendProfileInfoKey))
+ XCTAssertTrue(defaults.bool(forKey: UpdateSettings.migrationKey))
+ }
+
+ func testApplyRepairsLegacyDisabledAutomaticChecksOnce() {
+ let defaults = makeDefaults()
+ defaults.set(false, forKey: UpdateSettings.automaticChecksKey)
+ defaults.set(0, forKey: UpdateSettings.scheduledCheckIntervalKey)
+ defaults.set(true, forKey: UpdateSettings.automaticallyUpdateKey)
+
+ UpdateSettings.apply(to: defaults)
+
+ XCTAssertTrue(defaults.bool(forKey: UpdateSettings.automaticChecksKey))
+ XCTAssertEqual(defaults.double(forKey: UpdateSettings.scheduledCheckIntervalKey), UpdateSettings.scheduledCheckInterval)
+ XCTAssertTrue(defaults.bool(forKey: UpdateSettings.automaticallyUpdateKey))
+
+ defaults.set(false, forKey: UpdateSettings.automaticChecksKey)
+ UpdateSettings.apply(to: defaults)
+
+ XCTAssertFalse(defaults.bool(forKey: UpdateSettings.automaticChecksKey))
+ }
+
+ private func makeDefaults() -> UserDefaults {
+ let suiteName = "UpdateSettingsTests.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ fatalError("Failed to create isolated UserDefaults suite")
+ }
+ defaults.removePersistentDomain(forName: suiteName)
+ return defaults
+ }
+}
+
+
+@MainActor
+final class CommandPaletteOverlayPromotionPolicyTests: XCTestCase {
+ func testShouldPromoteWhenBecomingVisible() {
+ XCTAssertTrue(
+ CommandPaletteOverlayPromotionPolicy.shouldPromote(
+ previouslyVisible: false,
+ isVisible: true
+ )
+ )
+ }
+
+ func testShouldNotPromoteWhenAlreadyVisible() {
+ XCTAssertFalse(
+ CommandPaletteOverlayPromotionPolicy.shouldPromote(
+ previouslyVisible: true,
+ isVisible: true
+ )
+ )
+ }
+
+ func testShouldNotPromoteWhenHidden() {
+ XCTAssertFalse(
+ CommandPaletteOverlayPromotionPolicy.shouldPromote(
+ previouslyVisible: true,
+ isVisible: false
+ )
+ )
+ XCTAssertFalse(
+ CommandPaletteOverlayPromotionPolicy.shouldPromote(
+ previouslyVisible: false,
+ isVisible: false
+ )
+ )
+ }
+}
diff --git a/cmuxTests/SidebarOrderingTests.swift b/cmuxTests/SidebarOrderingTests.swift
new file mode 100644
index 00000000..e9301bb6
--- /dev/null
+++ b/cmuxTests/SidebarOrderingTests.swift
@@ -0,0 +1,941 @@
+import XCTest
+import AppKit
+import SwiftUI
+import UniformTypeIdentifiers
+import WebKit
+import ObjectiveC.runtime
+import Bonsplit
+import UserNotifications
+
+#if canImport(cmux_DEV)
+@testable import cmux_DEV
+#elseif canImport(cmux)
+@testable import cmux
+#endif
+
+final class SidebarActiveForegroundColorTests: XCTestCase {
+ func testLightAppearanceUsesBlackWithRequestedOpacity() {
+ guard let lightAppearance = NSAppearance(named: .aqua),
+ let color = sidebarActiveForegroundNSColor(
+ opacity: 0.8,
+ appAppearance: lightAppearance
+ ).usingColorSpace(.sRGB) else {
+ XCTFail("Expected sRGB-convertible color")
+ return
+ }
+
+ XCTAssertEqual(color.redComponent, 0, accuracy: 0.001)
+ XCTAssertEqual(color.greenComponent, 0, accuracy: 0.001)
+ XCTAssertEqual(color.blueComponent, 0, accuracy: 0.001)
+ XCTAssertEqual(color.alphaComponent, 0.8, accuracy: 0.001)
+ }
+
+ func testDarkAppearanceUsesWhiteWithRequestedOpacity() {
+ guard let darkAppearance = NSAppearance(named: .darkAqua),
+ let color = sidebarActiveForegroundNSColor(
+ opacity: 0.65,
+ appAppearance: darkAppearance
+ ).usingColorSpace(.sRGB) else {
+ XCTFail("Expected sRGB-convertible color")
+ return
+ }
+
+ XCTAssertEqual(color.redComponent, 1, accuracy: 0.001)
+ XCTAssertEqual(color.greenComponent, 1, accuracy: 0.001)
+ XCTAssertEqual(color.blueComponent, 1, accuracy: 0.001)
+ XCTAssertEqual(color.alphaComponent, 0.65, accuracy: 0.001)
+ }
+}
+
+
+final class SidebarBranchLayoutSettingsTests: XCTestCase {
+ func testDefaultUsesVerticalLayout() {
+ let suiteName = "SidebarBranchLayoutSettingsTests.Default.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ XCTAssertTrue(SidebarBranchLayoutSettings.usesVerticalLayout(defaults: defaults))
+ }
+
+ func testStoredPreferenceOverridesDefault() {
+ let suiteName = "SidebarBranchLayoutSettingsTests.Stored.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ defaults.set(false, forKey: SidebarBranchLayoutSettings.key)
+ XCTAssertFalse(SidebarBranchLayoutSettings.usesVerticalLayout(defaults: defaults))
+
+ defaults.set(true, forKey: SidebarBranchLayoutSettings.key)
+ XCTAssertTrue(SidebarBranchLayoutSettings.usesVerticalLayout(defaults: defaults))
+ }
+}
+
+
+final class SidebarActiveTabIndicatorSettingsTests: XCTestCase {
+ func testDefaultStyleWhenUnset() {
+ let suiteName = "SidebarActiveTabIndicatorSettingsTests.Default.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ defaults.removeObject(forKey: SidebarActiveTabIndicatorSettings.styleKey)
+ XCTAssertEqual(
+ SidebarActiveTabIndicatorSettings.current(defaults: defaults),
+ SidebarActiveTabIndicatorSettings.defaultStyle
+ )
+ }
+
+ func testStoredStyleParsesAndInvalidFallsBack() {
+ let suiteName = "SidebarActiveTabIndicatorSettingsTests.Stored.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ defaults.set(SidebarActiveTabIndicatorStyle.leftRail.rawValue, forKey: SidebarActiveTabIndicatorSettings.styleKey)
+ XCTAssertEqual(SidebarActiveTabIndicatorSettings.current(defaults: defaults), .leftRail)
+
+ defaults.set("rail", forKey: SidebarActiveTabIndicatorSettings.styleKey)
+ XCTAssertEqual(SidebarActiveTabIndicatorSettings.current(defaults: defaults), .leftRail)
+
+ defaults.set("not-a-style", forKey: SidebarActiveTabIndicatorSettings.styleKey)
+ XCTAssertEqual(
+ SidebarActiveTabIndicatorSettings.current(defaults: defaults),
+ SidebarActiveTabIndicatorSettings.defaultStyle
+ )
+ }
+}
+
+
+final class SidebarRemoteErrorCopySupportTests: XCTestCase {
+ func testMenuLabelIsNilWhenThereAreNoErrors() {
+ XCTAssertNil(SidebarRemoteErrorCopySupport.menuLabel(for: []))
+ XCTAssertNil(SidebarRemoteErrorCopySupport.clipboardText(for: []))
+ }
+
+ func testSingleErrorUsesCopyErrorLabelAndSingleLinePayload() {
+ let entries = [
+ SidebarRemoteErrorCopyEntry(
+ workspaceTitle: "alpha",
+ target: "devbox:22",
+ detail: "failed to start reverse relay"
+ )
+ ]
+
+ XCTAssertEqual(SidebarRemoteErrorCopySupport.menuLabel(for: entries), "Copy Error")
+ XCTAssertEqual(
+ SidebarRemoteErrorCopySupport.clipboardText(for: entries),
+ "SSH error (devbox:22): failed to start reverse relay"
+ )
+ }
+
+ func testMultipleErrorsUseCopyErrorsLabelAndEnumeratedPayload() {
+ let entries = [
+ SidebarRemoteErrorCopyEntry(
+ workspaceTitle: "alpha",
+ target: "devbox-a:22",
+ detail: "connection timed out"
+ ),
+ SidebarRemoteErrorCopyEntry(
+ workspaceTitle: "beta",
+ target: "devbox-b:22",
+ detail: "permission denied"
+ ),
+ ]
+
+ XCTAssertEqual(SidebarRemoteErrorCopySupport.menuLabel(for: entries), "Copy Errors")
+ XCTAssertEqual(
+ SidebarRemoteErrorCopySupport.clipboardText(for: entries),
+ """
+ 1. alpha (devbox-a:22): connection timed out
+ 2. beta (devbox-b:22): permission denied
+ """
+ )
+ }
+
+ func testClipboardTextSingleEntryUsesStructuredEntryFields() {
+ let entry = SidebarRemoteErrorCopyEntry(
+ workspaceTitle: "alpha",
+ target: "devbox:22",
+ detail: "failed to bootstrap daemon"
+ )
+ XCTAssertEqual(
+ SidebarRemoteErrorCopySupport.clipboardText(for: [entry]),
+ "SSH error (devbox:22): failed to bootstrap daemon"
+ )
+ }
+}
+
+
+final class SidebarBranchOrderingTests: XCTestCase {
+
+ func testOrderedUniqueBranchesDedupesByNameAndMergesDirtyState() {
+ let first = UUID()
+ let second = UUID()
+ let third = UUID()
+
+ let branches = SidebarBranchOrdering.orderedUniqueBranches(
+ orderedPanelIds: [first, second, third],
+ panelBranches: [
+ first: SidebarGitBranchState(branch: "main", isDirty: false),
+ second: SidebarGitBranchState(branch: "feature", isDirty: false),
+ third: SidebarGitBranchState(branch: "main", isDirty: true)
+ ],
+ fallbackBranch: SidebarGitBranchState(branch: "fallback", isDirty: false)
+ )
+
+ XCTAssertEqual(
+ branches,
+ [
+ SidebarBranchOrdering.BranchEntry(name: "main", isDirty: true),
+ SidebarBranchOrdering.BranchEntry(name: "feature", isDirty: false)
+ ]
+ )
+ }
+
+ func testOrderedUniqueBranchesUsesFallbackWhenNoPanelBranchesExist() {
+ let branches = SidebarBranchOrdering.orderedUniqueBranches(
+ orderedPanelIds: [],
+ panelBranches: [:],
+ fallbackBranch: SidebarGitBranchState(branch: "fallback", isDirty: true)
+ )
+
+ XCTAssertEqual(
+ branches,
+ [SidebarBranchOrdering.BranchEntry(name: "fallback", isDirty: true)]
+ )
+ }
+
+ func testOrderedUniqueBranchDirectoryEntriesDedupesPairsAndMergesDirtyState() {
+ let first = UUID()
+ let second = UUID()
+ let third = UUID()
+ let fourth = UUID()
+ let fifth = UUID()
+
+ let rows = SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries(
+ orderedPanelIds: [first, second, third, fourth, fifth],
+ panelBranches: [
+ first: SidebarGitBranchState(branch: "main", isDirty: false),
+ second: SidebarGitBranchState(branch: "feature", isDirty: false),
+ third: SidebarGitBranchState(branch: "main", isDirty: true),
+ fourth: SidebarGitBranchState(branch: "main", isDirty: false)
+ ],
+ panelDirectories: [
+ first: "/repo/a",
+ second: "/repo/b",
+ third: "/repo/a",
+ fourth: "/repo/d",
+ fifth: "/repo/e"
+ ],
+ defaultDirectory: "/repo/default",
+ fallbackBranch: SidebarGitBranchState(branch: "fallback", isDirty: false)
+ )
+
+ XCTAssertEqual(
+ rows,
+ [
+ SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: true, directory: "/repo/a"),
+ SidebarBranchOrdering.BranchDirectoryEntry(branch: "feature", isDirty: false, directory: "/repo/b"),
+ SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: false, directory: "/repo/d"),
+ SidebarBranchOrdering.BranchDirectoryEntry(branch: nil, isDirty: false, directory: "/repo/e")
+ ]
+ )
+ }
+
+ func testOrderedUniqueBranchDirectoryEntriesUsesFallbackBranchWhenPanelBranchesMissing() {
+ let first = UUID()
+ let second = UUID()
+
+ let rows = SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries(
+ orderedPanelIds: [first, second],
+ panelBranches: [:],
+ panelDirectories: [
+ first: "/repo/one",
+ second: "/repo/two"
+ ],
+ defaultDirectory: "/repo/default",
+ fallbackBranch: SidebarGitBranchState(branch: "main", isDirty: true)
+ )
+
+ XCTAssertEqual(
+ rows,
+ [
+ SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: true, directory: "/repo/one"),
+ SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: true, directory: "/repo/two")
+ ]
+ )
+ }
+
+ func testOrderedUniqueBranchDirectoryEntriesFallsBackWhenNoPanelsExist() {
+ let rows = SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries(
+ orderedPanelIds: [],
+ panelBranches: [:],
+ panelDirectories: [:],
+ defaultDirectory: "/repo/default",
+ fallbackBranch: SidebarGitBranchState(branch: "main", isDirty: false)
+ )
+
+ XCTAssertEqual(
+ rows,
+ [SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: false, directory: "/repo/default")]
+ )
+ }
+
+ func testOrderedUniquePullRequestsFollowsPanelOrderAcrossSplitsAndTabs() {
+ let first = UUID()
+ let second = UUID()
+ let third = UUID()
+ let fourth = UUID()
+
+ let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests(
+ orderedPanelIds: [first, second, third, fourth],
+ panelPullRequests: [
+ first: pullRequestState(
+ number: 337,
+ label: "PR",
+ url: "https://github.com/manaflow-ai/cmux/pull/337",
+ status: .open
+ ),
+ second: pullRequestState(
+ number: 18,
+ label: "MR",
+ url: "https://gitlab.com/manaflow/cmux/-/merge_requests/18",
+ status: .open
+ ),
+ third: pullRequestState(
+ number: 337,
+ label: "PR",
+ url: "https://github.com/manaflow-ai/cmux/pull/337",
+ status: .merged
+ ),
+ fourth: pullRequestState(
+ number: 92,
+ label: "PR",
+ url: "https://bitbucket.org/manaflow/cmux/pull-requests/92",
+ status: .closed
+ )
+ ],
+ fallbackPullRequest: pullRequestState(
+ number: 1,
+ label: "PR",
+ url: "https://example.invalid/fallback/1",
+ status: .open
+ )
+ )
+
+ XCTAssertEqual(
+ pullRequests.map { "\($0.label)#\($0.number)" },
+ ["PR#337", "MR#18", "PR#92"]
+ )
+ XCTAssertEqual(
+ pullRequests.map(\.status),
+ [.merged, .open, .closed]
+ )
+ }
+
+ func testOrderedUniquePullRequestsTreatsSameNumberDifferentLabelsAsDistinct() {
+ let first = UUID()
+ let second = UUID()
+
+ let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests(
+ orderedPanelIds: [first, second],
+ panelPullRequests: [
+ first: pullRequestState(
+ number: 42,
+ label: "PR",
+ url: "https://github.com/manaflow-ai/cmux/pull/42",
+ status: .open
+ ),
+ second: pullRequestState(
+ number: 42,
+ label: "MR",
+ url: "https://gitlab.com/manaflow/cmux/-/merge_requests/42",
+ status: .open
+ )
+ ],
+ fallbackPullRequest: nil
+ )
+
+ XCTAssertEqual(
+ pullRequests.map { "\($0.label)#\($0.number)" },
+ ["PR#42", "MR#42"]
+ )
+ }
+
+ func testOrderedUniquePullRequestsTreatsSameNumberAndLabelDifferentUrlsAsDistinct() {
+ let first = UUID()
+ let second = UUID()
+
+ let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests(
+ orderedPanelIds: [first, second],
+ panelPullRequests: [
+ first: pullRequestState(
+ number: 42,
+ label: "PR",
+ url: "https://github.com/manaflow-ai/cmux/pull/42",
+ status: .open
+ ),
+ second: pullRequestState(
+ number: 42,
+ label: "PR",
+ url: "https://github.com/manaflow-ai/other-repo/pull/42",
+ status: .open
+ )
+ ],
+ fallbackPullRequest: nil
+ )
+
+ XCTAssertEqual(
+ pullRequests.map(\.url.absoluteString),
+ [
+ "https://github.com/manaflow-ai/cmux/pull/42",
+ "https://github.com/manaflow-ai/other-repo/pull/42"
+ ]
+ )
+ }
+
+ func testOrderedUniquePullRequestsPrefersEntryWithChecksWhenStatusesMatch() {
+ let first = UUID()
+ let second = UUID()
+
+ let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests(
+ orderedPanelIds: [first, second],
+ panelPullRequests: [
+ first: pullRequestState(
+ number: 42,
+ label: "PR",
+ url: "https://github.com/manaflow-ai/cmux/pull/42",
+ status: .open
+ ),
+ second: pullRequestState(
+ number: 42,
+ label: "PR",
+ url: "https://github.com/manaflow-ai/cmux/pull/42",
+ status: .open,
+ checks: .pass
+ )
+ ],
+ fallbackPullRequest: nil
+ )
+
+ XCTAssertEqual(pullRequests.count, 1)
+ XCTAssertEqual(pullRequests.first?.checks, .pass)
+ }
+
+ @MainActor
+ func testUpdatePanelPullRequestPreservesExistingChecksWhenUpdateOmitsThem() {
+ let workspace = Workspace(title: "Tests", workingDirectory: FileManager.default.currentDirectoryPath, portOrdinal: 0)
+ guard let panelId = workspace.focusedPanelId else {
+ XCTFail("Expected focused panel for new workspace")
+ return
+ }
+
+ workspace.updatePanelPullRequest(
+ panelId: panelId,
+ number: 42,
+ label: "PR",
+ url: URL(string: "https://github.com/manaflow-ai/cmux/pull/42")!,
+ status: .open,
+ checks: .pass
+ )
+ workspace.updatePanelPullRequest(
+ panelId: panelId,
+ number: 42,
+ label: "PR",
+ url: URL(string: "https://github.com/manaflow-ai/cmux/pull/42")!,
+ status: .open
+ )
+
+ XCTAssertEqual(workspace.panelPullRequests[panelId]?.checks, .pass)
+ XCTAssertEqual(workspace.pullRequest?.checks, .pass)
+ }
+
+ func testOrderedUniquePullRequestsUsesFallbackWhenNoPanelPullRequestsExist() {
+ let fallback = pullRequestState(
+ number: 11,
+ label: "PR",
+ url: "https://github.com/manaflow-ai/cmux/pull/11",
+ status: .open
+ )
+ let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests(
+ orderedPanelIds: [],
+ panelPullRequests: [:],
+ fallbackPullRequest: fallback
+ )
+
+ XCTAssertEqual(pullRequests, [fallback])
+ }
+
+ @MainActor
+ func testUpdatePanelGitBranchClearsFocusedPullRequestWhenBranchChanges() {
+ let workspace = Workspace(title: "Tests", workingDirectory: FileManager.default.currentDirectoryPath, portOrdinal: 0)
+ guard let panelId = workspace.focusedPanelId else {
+ XCTFail("Expected focused panel for new workspace")
+ return
+ }
+
+ workspace.updatePanelGitBranch(panelId: panelId, branch: "feature/sidebar-pr", isDirty: false)
+ workspace.updatePanelPullRequest(
+ panelId: panelId,
+ number: 1629,
+ label: "PR",
+ url: URL(string: "https://github.com/manaflow-ai/cmux/pull/1629")!,
+ status: .open
+ )
+
+ workspace.updatePanelGitBranch(panelId: panelId, branch: "main", isDirty: false)
+
+ XCTAssertNil(workspace.pullRequest)
+ XCTAssertNil(workspace.panelPullRequests[panelId])
+ XCTAssertTrue(workspace.sidebarPullRequestsInDisplayOrder().isEmpty)
+ }
+
+ @MainActor
+ func testSidebarPullRequestsHideBranchMismatches() {
+ let workspace = Workspace(title: "Tests", workingDirectory: FileManager.default.currentDirectoryPath, portOrdinal: 0)
+ guard let panelId = workspace.focusedPanelId else {
+ XCTFail("Expected focused panel for new workspace")
+ return
+ }
+
+ workspace.updatePanelGitBranch(panelId: panelId, branch: "main", isDirty: false)
+ workspace.updatePanelPullRequest(
+ panelId: panelId,
+ number: 1629,
+ label: "PR",
+ url: URL(string: "https://github.com/manaflow-ai/cmux/pull/1629")!,
+ status: .open,
+ branch: "feature/sidebar-pr"
+ )
+
+ XCTAssertTrue(workspace.sidebarPullRequestsInDisplayOrder().isEmpty)
+ }
+
+ private func pullRequestState(
+ number: Int,
+ label: String,
+ url: String,
+ status: SidebarPullRequestStatus,
+ branch: String? = nil,
+ checks: SidebarPullRequestChecksStatus? = nil
+ ) -> SidebarPullRequestState {
+ SidebarPullRequestState(
+ number: number,
+ label: label,
+ url: URL(string: url)!,
+ status: status,
+ branch: branch,
+ checks: checks
+ )
+ }
+}
+
+
+final class SidebarDropPlannerTests: XCTestCase {
+ func testNoIndicatorForNoOpEdges() {
+ let first = UUID()
+ let second = UUID()
+ let third = UUID()
+ let tabIds = [first, second, third]
+
+ XCTAssertNil(
+ SidebarDropPlanner.indicator(
+ draggedTabId: first,
+ targetTabId: first,
+ tabIds: tabIds,
+ pinnedTabIds: []
+ )
+ )
+ XCTAssertNil(
+ SidebarDropPlanner.indicator(
+ draggedTabId: third,
+ targetTabId: nil,
+ tabIds: tabIds,
+ pinnedTabIds: []
+ )
+ )
+ }
+
+ func testNoIndicatorWhenOnlyOneTabExists() {
+ let only = UUID()
+ XCTAssertNil(
+ SidebarDropPlanner.indicator(
+ draggedTabId: only,
+ targetTabId: nil,
+ tabIds: [only],
+ pinnedTabIds: []
+ )
+ )
+ XCTAssertNil(
+ SidebarDropPlanner.indicator(
+ draggedTabId: only,
+ targetTabId: only,
+ tabIds: [only],
+ pinnedTabIds: []
+ )
+ )
+ }
+
+ func testIndicatorAppearsForRealMoveToEnd() {
+ let first = UUID()
+ let second = UUID()
+ let third = UUID()
+ let tabIds = [first, second, third]
+
+ let indicator = SidebarDropPlanner.indicator(
+ draggedTabId: second,
+ targetTabId: nil,
+ tabIds: tabIds,
+ pinnedTabIds: []
+ )
+ XCTAssertEqual(indicator?.tabId, nil)
+ XCTAssertEqual(indicator?.edge, .bottom)
+ }
+
+ func testTargetIndexForMoveToEndFromMiddle() {
+ let first = UUID()
+ let second = UUID()
+ let third = UUID()
+ let tabIds = [first, second, third]
+
+ let index = SidebarDropPlanner.targetIndex(
+ draggedTabId: second,
+ targetTabId: nil,
+ indicator: SidebarDropIndicator(tabId: nil, edge: .bottom),
+ tabIds: tabIds,
+ pinnedTabIds: []
+ )
+ XCTAssertEqual(index, 2)
+ }
+
+ func testNoIndicatorForSelfDropInMiddle() {
+ let first = UUID()
+ let second = UUID()
+ let third = UUID()
+ let tabIds = [first, second, third]
+
+ XCTAssertNil(
+ SidebarDropPlanner.indicator(
+ draggedTabId: second,
+ targetTabId: second,
+ tabIds: tabIds,
+ pinnedTabIds: []
+ )
+ )
+ }
+
+ func testPointerEdgeTopCanSuppressNoOpWhenDraggingFirstOverSecond() {
+ let first = UUID()
+ let second = UUID()
+ let third = UUID()
+ let tabIds = [first, second, third]
+
+ XCTAssertNil(
+ SidebarDropPlanner.indicator(
+ draggedTabId: first,
+ targetTabId: second,
+ tabIds: tabIds,
+ pinnedTabIds: [],
+ pointerY: 2,
+ targetHeight: 40
+ )
+ )
+ }
+
+ func testPointerEdgeBottomAllowsMoveWhenDraggingFirstOverSecond() {
+ let first = UUID()
+ let second = UUID()
+ let third = UUID()
+ let tabIds = [first, second, third]
+
+ let indicator = SidebarDropPlanner.indicator(
+ draggedTabId: first,
+ targetTabId: second,
+ tabIds: tabIds,
+ pinnedTabIds: [],
+ pointerY: 38,
+ targetHeight: 40
+ )
+ XCTAssertEqual(indicator?.tabId, third)
+ XCTAssertEqual(indicator?.edge, .top)
+ XCTAssertEqual(
+ SidebarDropPlanner.targetIndex(
+ draggedTabId: first,
+ targetTabId: second,
+ indicator: indicator,
+ tabIds: tabIds,
+ pinnedTabIds: []
+ ),
+ 1
+ )
+ }
+
+ func testEquivalentBoundaryInputsResolveToSingleCanonicalIndicator() {
+ let first = UUID()
+ let second = UUID()
+ let third = UUID()
+ let tabIds = [first, second, third]
+
+ let fromBottomOfFirst = SidebarDropPlanner.indicator(
+ draggedTabId: third,
+ targetTabId: first,
+ tabIds: tabIds,
+ pinnedTabIds: [],
+ pointerY: 38,
+ targetHeight: 40
+ )
+ let fromTopOfSecond = SidebarDropPlanner.indicator(
+ draggedTabId: third,
+ targetTabId: second,
+ tabIds: tabIds,
+ pinnedTabIds: [],
+ pointerY: 2,
+ targetHeight: 40
+ )
+
+ XCTAssertEqual(fromBottomOfFirst?.tabId, second)
+ XCTAssertEqual(fromBottomOfFirst?.edge, .top)
+ XCTAssertEqual(fromTopOfSecond?.tabId, second)
+ XCTAssertEqual(fromTopOfSecond?.edge, .top)
+ }
+
+ func testPointerEdgeBottomSuppressesNoOpWhenDraggingLastOverSecond() {
+ let first = UUID()
+ let second = UUID()
+ let third = UUID()
+ let tabIds = [first, second, third]
+
+ XCTAssertNil(
+ SidebarDropPlanner.indicator(
+ draggedTabId: third,
+ targetTabId: second,
+ tabIds: tabIds,
+ pinnedTabIds: [],
+ pointerY: 38,
+ targetHeight: 40
+ )
+ )
+ }
+
+ func testIndicatorSnapsUnpinnedDropToFirstUnpinnedBoundaryWhenHoveringPinnedWorkspace() {
+ let pinnedA = UUID()
+ let pinnedB = UUID()
+ let unpinnedA = UUID()
+ let unpinnedB = UUID()
+ let tabIds = [pinnedA, pinnedB, unpinnedA, unpinnedB]
+ let pinnedIds: Set = [pinnedA, pinnedB]
+
+ let indicator = SidebarDropPlanner.indicator(
+ draggedTabId: unpinnedB,
+ targetTabId: pinnedA,
+ tabIds: tabIds,
+ pinnedTabIds: pinnedIds,
+ pointerY: 2,
+ targetHeight: 40
+ )
+
+ XCTAssertEqual(indicator?.tabId, unpinnedA)
+ XCTAssertEqual(indicator?.edge, .top)
+ }
+
+ func testTargetIndexSnapsUnpinnedDropToFirstUnpinnedBoundaryWhenHoveringPinnedWorkspace() {
+ let pinnedA = UUID()
+ let pinnedB = UUID()
+ let unpinnedA = UUID()
+ let unpinnedB = UUID()
+ let tabIds = [pinnedA, pinnedB, unpinnedA, unpinnedB]
+ let pinnedIds: Set = [pinnedA, pinnedB]
+
+ let targetIndex = SidebarDropPlanner.targetIndex(
+ draggedTabId: unpinnedB,
+ targetTabId: pinnedA,
+ indicator: SidebarDropIndicator(tabId: pinnedA, edge: .top),
+ tabIds: tabIds,
+ pinnedTabIds: pinnedIds
+ )
+
+ XCTAssertEqual(targetIndex, 2)
+ }
+
+}
+
+
+final class SidebarDragAutoScrollPlannerTests: XCTestCase {
+ func testAutoScrollPlanTriggersNearTopAndBottomOnly() {
+ let topPlan = SidebarDragAutoScrollPlanner.plan(distanceToTop: 4, distanceToBottom: 96, edgeInset: 44, minStep: 2, maxStep: 12)
+ XCTAssertEqual(topPlan?.direction, .up)
+ XCTAssertNotNil(topPlan)
+
+ let bottomPlan = SidebarDragAutoScrollPlanner.plan(distanceToTop: 96, distanceToBottom: 4, edgeInset: 44, minStep: 2, maxStep: 12)
+ XCTAssertEqual(bottomPlan?.direction, .down)
+ XCTAssertNotNil(bottomPlan)
+
+ XCTAssertNil(
+ SidebarDragAutoScrollPlanner.plan(distanceToTop: 60, distanceToBottom: 60, edgeInset: 44, minStep: 2, maxStep: 12)
+ )
+ }
+
+ func testAutoScrollPlanSpeedsUpCloserToEdge() {
+ let nearTop = SidebarDragAutoScrollPlanner.plan(distanceToTop: 1, distanceToBottom: 99, edgeInset: 44, minStep: 2, maxStep: 12)
+ let midTop = SidebarDragAutoScrollPlanner.plan(distanceToTop: 22, distanceToBottom: 78, edgeInset: 44, minStep: 2, maxStep: 12)
+
+ XCTAssertNotNil(nearTop)
+ XCTAssertNotNil(midTop)
+ XCTAssertGreaterThan(nearTop?.pointsPerTick ?? 0, midTop?.pointsPerTick ?? 0)
+ }
+
+ func testAutoScrollPlanStillTriggersWhenPointerIsPastEdge() {
+ let aboveTop = SidebarDragAutoScrollPlanner.plan(distanceToTop: -500, distanceToBottom: 600, edgeInset: 44, minStep: 2, maxStep: 12)
+ XCTAssertEqual(aboveTop?.direction, .up)
+ XCTAssertEqual(aboveTop?.pointsPerTick, 12)
+
+ let belowBottom = SidebarDragAutoScrollPlanner.plan(distanceToTop: 600, distanceToBottom: -500, edgeInset: 44, minStep: 2, maxStep: 12)
+ XCTAssertEqual(belowBottom?.direction, .down)
+ XCTAssertEqual(belowBottom?.pointsPerTick, 12)
+ }
+}
+
+
+final class TerminalControllerSidebarDedupeTests: XCTestCase {
+ func testShouldReplaceStatusEntryReturnsFalseForUnchangedPayload() {
+ let current = SidebarStatusEntry(
+ key: "agent",
+ value: "idle",
+ icon: "bolt",
+ color: "#ffffff",
+ timestamp: Date(timeIntervalSince1970: 123)
+ )
+ XCTAssertFalse(
+ TerminalController.shouldReplaceStatusEntry(
+ current: current,
+ key: "agent",
+ value: "idle",
+ icon: "bolt",
+ color: "#ffffff",
+ url: nil,
+ priority: 0,
+ format: .plain
+ )
+ )
+ }
+
+ func testShouldReplaceStatusEntryReturnsTrueWhenValueChanges() {
+ let current = SidebarStatusEntry(
+ key: "agent",
+ value: "idle",
+ icon: "bolt",
+ color: "#ffffff",
+ timestamp: Date(timeIntervalSince1970: 123)
+ )
+ XCTAssertTrue(
+ TerminalController.shouldReplaceStatusEntry(
+ current: current,
+ key: "agent",
+ value: "running",
+ icon: "bolt",
+ color: "#ffffff",
+ url: nil,
+ priority: 0,
+ format: .plain
+ )
+ )
+ }
+
+ func testShouldReplaceProgressReturnsFalseForUnchangedPayload() {
+ XCTAssertFalse(
+ TerminalController.shouldReplaceProgress(
+ current: SidebarProgressState(value: 0.42, label: "indexing"),
+ value: 0.42,
+ label: "indexing"
+ )
+ )
+ }
+
+ func testShouldReplaceGitBranchReturnsFalseForUnchangedPayload() {
+ XCTAssertFalse(
+ TerminalController.shouldReplaceGitBranch(
+ current: SidebarGitBranchState(branch: "main", isDirty: true),
+ branch: "main",
+ isDirty: true
+ )
+ )
+ }
+
+ func testShouldReplacePortsIgnoresOrderAndDuplicates() {
+ XCTAssertFalse(
+ TerminalController.shouldReplacePorts(
+ current: [9229, 3000],
+ next: [3000, 9229, 3000]
+ )
+ )
+ XCTAssertTrue(
+ TerminalController.shouldReplacePorts(
+ current: [9229, 3000],
+ next: [3000]
+ )
+ )
+ }
+
+ func testExplicitSocketScopeParsesValidUUIDTabAndPanel() {
+ let workspaceId = UUID()
+ let panelId = UUID()
+ let scope = TerminalController.explicitSocketScope(
+ options: [
+ "tab": workspaceId.uuidString,
+ "panel": panelId.uuidString
+ ]
+ )
+ XCTAssertEqual(scope?.workspaceId, workspaceId)
+ XCTAssertEqual(scope?.panelId, panelId)
+ }
+
+ func testExplicitSocketScopeAcceptsSurfaceAlias() {
+ let workspaceId = UUID()
+ let panelId = UUID()
+ let scope = TerminalController.explicitSocketScope(
+ options: [
+ "tab": workspaceId.uuidString,
+ "surface": panelId.uuidString
+ ]
+ )
+ XCTAssertEqual(scope?.workspaceId, workspaceId)
+ XCTAssertEqual(scope?.panelId, panelId)
+ }
+
+ func testExplicitSocketScopeRejectsMissingOrInvalidValues() {
+ XCTAssertNil(TerminalController.explicitSocketScope(options: [:]))
+ XCTAssertNil(TerminalController.explicitSocketScope(options: ["tab": "workspace:1", "panel": UUID().uuidString]))
+ XCTAssertNil(TerminalController.explicitSocketScope(options: ["tab": UUID().uuidString, "panel": "surface:1"]))
+ }
+
+ func testNormalizeReportedDirectoryTrimsWhitespace() {
+ XCTAssertEqual(
+ TerminalController.normalizeReportedDirectory(" /Users/cmux/project "),
+ "/Users/cmux/project"
+ )
+ }
+
+ func testNormalizeReportedDirectoryResolvesFileURL() {
+ XCTAssertEqual(
+ TerminalController.normalizeReportedDirectory("file:///Users/cmux/project"),
+ "/Users/cmux/project"
+ )
+ }
+
+ func testNormalizeReportedDirectoryLeavesInvalidURLTrimmed() {
+ XCTAssertEqual(
+ TerminalController.normalizeReportedDirectory(" file://bad host "),
+ "file://bad host"
+ )
+ }
+}
diff --git a/cmuxTests/TabManagerUnitTests.swift b/cmuxTests/TabManagerUnitTests.swift
new file mode 100644
index 00000000..d6942378
--- /dev/null
+++ b/cmuxTests/TabManagerUnitTests.swift
@@ -0,0 +1,976 @@
+import XCTest
+import AppKit
+import SwiftUI
+import UniformTypeIdentifiers
+import WebKit
+import ObjectiveC.runtime
+import Bonsplit
+import UserNotifications
+
+#if canImport(cmux_DEV)
+@testable import cmux_DEV
+#elseif canImport(cmux)
+@testable import cmux
+#endif
+
+let lastSurfaceCloseShortcutDefaultsKey = "closeWorkspaceOnLastSurfaceShortcut"
+
+func drainMainQueue() {
+ let expectation = XCTestExpectation(description: "drain main queue")
+ DispatchQueue.main.async {
+ expectation.fulfill()
+ }
+ XCTWaiter().wait(for: [expectation], timeout: 1.0)
+}
+
+@MainActor
+final class TabManagerChildExitCloseTests: XCTestCase {
+ func testChildExitOnLastPanelClosesSelectedWorkspaceAndKeepsIndexStable() {
+ let manager = TabManager()
+ let first = manager.tabs[0]
+ let second = manager.addWorkspace()
+ let third = manager.addWorkspace()
+
+ manager.selectWorkspace(second)
+ XCTAssertEqual(manager.selectedTabId, second.id)
+
+ guard let secondPanelId = second.focusedPanelId else {
+ XCTFail("Expected focused panel in selected workspace")
+ return
+ }
+
+ manager.closePanelAfterChildExited(tabId: second.id, surfaceId: secondPanelId)
+
+ XCTAssertEqual(manager.tabs.map(\.id), [first.id, third.id])
+ XCTAssertEqual(
+ manager.selectedTabId,
+ third.id,
+ "Expected selection to stay at the same index after deleting the selected workspace"
+ )
+ }
+
+ func testChildExitOnLastPanelInLastWorkspaceSelectsPreviousWorkspace() {
+ let manager = TabManager()
+ let first = manager.tabs[0]
+ let second = manager.addWorkspace()
+
+ manager.selectWorkspace(second)
+ XCTAssertEqual(manager.selectedTabId, second.id)
+
+ guard let secondPanelId = second.focusedPanelId else {
+ XCTFail("Expected focused panel in selected workspace")
+ return
+ }
+
+ manager.closePanelAfterChildExited(tabId: second.id, surfaceId: secondPanelId)
+
+ XCTAssertEqual(manager.tabs.map(\.id), [first.id])
+ XCTAssertEqual(
+ manager.selectedTabId,
+ first.id,
+ "Expected previous workspace to be selected after closing the last-index workspace"
+ )
+ }
+
+ func testChildExitOnNonLastPanelClosesOnlyPanel() {
+ let manager = TabManager()
+ guard let workspace = manager.selectedWorkspace,
+ let initialPanelId = workspace.focusedPanelId else {
+ XCTFail("Expected selected workspace with focused panel")
+ return
+ }
+
+ guard let splitPanel = workspace.newTerminalSplit(from: initialPanelId, orientation: .horizontal) else {
+ XCTFail("Expected split terminal panel to be created")
+ return
+ }
+
+ let panelCountBefore = workspace.panels.count
+ manager.closePanelAfterChildExited(tabId: workspace.id, surfaceId: splitPanel.id)
+
+ XCTAssertEqual(manager.tabs.count, 1)
+ XCTAssertEqual(manager.tabs.first?.id, workspace.id)
+ XCTAssertEqual(workspace.panels.count, panelCountBefore - 1)
+ XCTAssertNotNil(workspace.panels[initialPanelId], "Expected sibling panel to remain")
+ }
+}
+
+
+@MainActor
+final class TabManagerWorkspaceOwnershipTests: XCTestCase {
+ func testCloseWorkspaceIgnoresWorkspaceNotOwnedByManager() {
+ let manager = TabManager()
+ _ = manager.addWorkspace()
+ let initialTabIds = manager.tabs.map(\.id)
+ let initialSelectedTabId = manager.selectedTabId
+
+ let externalWorkspace = Workspace(title: "External workspace")
+ let externalPanelCountBefore = externalWorkspace.panels.count
+ let externalPanelTitlesBefore = externalWorkspace.panelTitles
+
+ manager.closeWorkspace(externalWorkspace)
+
+ XCTAssertEqual(manager.tabs.map(\.id), initialTabIds)
+ XCTAssertEqual(manager.selectedTabId, initialSelectedTabId)
+ XCTAssertEqual(externalWorkspace.panels.count, externalPanelCountBefore)
+ XCTAssertEqual(externalWorkspace.panelTitles, externalPanelTitlesBefore)
+ }
+}
+
+
+@MainActor
+final class TabManagerCloseWorkspacesWithConfirmationTests: XCTestCase {
+ func testCloseWorkspacesWithConfirmationPromptsOnceAndClosesAcceptedWorkspaces() {
+ let manager = TabManager()
+ let second = manager.addWorkspace()
+ let third = manager.addWorkspace()
+ manager.setCustomTitle(tabId: manager.tabs[0].id, title: "Alpha")
+ manager.setCustomTitle(tabId: second.id, title: "Beta")
+ manager.setCustomTitle(tabId: third.id, title: "Gamma")
+
+ var prompts: [(title: String, message: String, acceptCmdD: Bool)] = []
+ manager.confirmCloseHandler = { title, message, acceptCmdD in
+ prompts.append((title, message, acceptCmdD))
+ return true
+ }
+
+ manager.closeWorkspacesWithConfirmation([manager.tabs[0].id, second.id], allowPinned: true)
+
+ let expectedMessage = String(
+ format: String(
+ localized: "dialog.closeWorkspaces.message",
+ defaultValue: "This will close %1$lld workspaces and all of their panels:\n%2$@"
+ ),
+ locale: .current,
+ Int64(2),
+ "• Alpha\n• Beta"
+ )
+ XCTAssertEqual(prompts.count, 1, "Expected a single confirmation prompt for multi-close")
+ XCTAssertEqual(
+ prompts.first?.title,
+ String(localized: "dialog.closeWorkspaces.title", defaultValue: "Close workspaces?")
+ )
+ XCTAssertEqual(prompts.first?.message, expectedMessage)
+ XCTAssertEqual(prompts.first?.acceptCmdD, false)
+ XCTAssertEqual(manager.tabs.map(\.title), ["Gamma"])
+ }
+
+ func testCloseWorkspacesWithConfirmationKeepsWorkspacesWhenCancelled() {
+ let manager = TabManager()
+ let second = manager.addWorkspace()
+ manager.setCustomTitle(tabId: manager.tabs[0].id, title: "Alpha")
+ manager.setCustomTitle(tabId: second.id, title: "Beta")
+
+ var prompts: [(title: String, message: String, acceptCmdD: Bool)] = []
+ manager.confirmCloseHandler = { title, message, acceptCmdD in
+ prompts.append((title, message, acceptCmdD))
+ return false
+ }
+
+ manager.closeWorkspacesWithConfirmation([manager.tabs[0].id, second.id], allowPinned: true)
+
+ let expectedMessage = String(
+ format: String(
+ localized: "dialog.closeWorkspacesWindow.message",
+ defaultValue: "This will close the current window, its %1$lld workspaces, and all of their panels:\n%2$@"
+ ),
+ locale: .current,
+ Int64(2),
+ "• Alpha\n• Beta"
+ )
+ XCTAssertEqual(prompts.count, 1)
+ XCTAssertEqual(
+ prompts.first?.title,
+ String(localized: "dialog.closeWindow.title", defaultValue: "Close window?")
+ )
+ XCTAssertEqual(prompts.first?.message, expectedMessage)
+ XCTAssertEqual(prompts.first?.acceptCmdD, true)
+ XCTAssertEqual(manager.tabs.map(\.title), ["Alpha", "Beta"])
+ }
+
+ func testCloseCurrentWorkspaceWithConfirmationUsesSidebarMultiSelection() {
+ let manager = TabManager()
+ let second = manager.addWorkspace()
+ let third = manager.addWorkspace()
+ manager.setCustomTitle(tabId: manager.tabs[0].id, title: "Alpha")
+ manager.setCustomTitle(tabId: second.id, title: "Beta")
+ manager.setCustomTitle(tabId: third.id, title: "Gamma")
+ manager.selectWorkspace(second)
+ manager.setSidebarSelectedWorkspaceIds([manager.tabs[0].id, second.id])
+
+ var prompts: [(title: String, message: String, acceptCmdD: Bool)] = []
+ manager.confirmCloseHandler = { title, message, acceptCmdD in
+ prompts.append((title, message, acceptCmdD))
+ return false
+ }
+
+ manager.closeCurrentWorkspaceWithConfirmation()
+
+ let expectedMessage = String(
+ format: String(
+ localized: "dialog.closeWorkspaces.message",
+ defaultValue: "This will close %1$lld workspaces and all of their panels:\n%2$@"
+ ),
+ locale: .current,
+ Int64(2),
+ "• Alpha\n• Beta"
+ )
+ XCTAssertEqual(prompts.count, 1, "Expected Cmd+Shift+W path to reuse the multi-close summary dialog")
+ XCTAssertEqual(
+ prompts.first?.title,
+ String(localized: "dialog.closeWorkspaces.title", defaultValue: "Close workspaces?")
+ )
+ XCTAssertEqual(prompts.first?.message, expectedMessage)
+ XCTAssertEqual(prompts.first?.acceptCmdD, false)
+ XCTAssertEqual(manager.tabs.map(\.title), ["Alpha", "Beta", "Gamma"])
+ }
+}
+
+
+@MainActor
+final class TabManagerCloseCurrentPanelTests: XCTestCase {
+ func testRuntimeCloseSkipsConfirmationWhenShellReportsPromptIdle() {
+ let manager = TabManager()
+ guard let workspace = manager.selectedWorkspace,
+ let panelId = workspace.focusedPanelId,
+ let terminalPanel = workspace.terminalPanel(for: panelId) else {
+ XCTFail("Expected selected workspace and focused terminal panel")
+ return
+ }
+
+ terminalPanel.surface.setNeedsConfirmCloseOverrideForTesting(true)
+ workspace.updatePanelShellActivityState(panelId: panelId, state: .promptIdle)
+
+ var promptCount = 0
+ manager.confirmCloseHandler = { _, _, _ in
+ promptCount += 1
+ return false
+ }
+
+ manager.closeRuntimeSurfaceWithConfirmation(tabId: workspace.id, surfaceId: panelId)
+ drainMainQueue()
+ drainMainQueue()
+
+ XCTAssertEqual(promptCount, 0, "Runtime closes should honor prompt-idle shell state")
+ XCTAssertNil(workspace.panels[panelId], "Expected the original panel to close")
+ XCTAssertEqual(workspace.panels.count, 1, "Expected a replacement surface after closing the last panel")
+ }
+
+ func testRuntimeClosePromptsWhenShellReportsRunningCommand() {
+ let manager = TabManager()
+ guard let workspace = manager.selectedWorkspace,
+ let panelId = workspace.focusedPanelId,
+ let terminalPanel = workspace.terminalPanel(for: panelId) else {
+ XCTFail("Expected selected workspace and focused terminal panel")
+ return
+ }
+
+ terminalPanel.surface.setNeedsConfirmCloseOverrideForTesting(false)
+ workspace.updatePanelShellActivityState(panelId: panelId, state: .commandRunning)
+
+ var promptCount = 0
+ manager.confirmCloseHandler = { _, _, _ in
+ promptCount += 1
+ return false
+ }
+
+ manager.closeRuntimeSurfaceWithConfirmation(tabId: workspace.id, surfaceId: panelId)
+
+ XCTAssertEqual(promptCount, 1, "Running commands should still require confirmation")
+ XCTAssertNotNil(workspace.panels[panelId], "Prompt rejection should keep the original panel open")
+ }
+
+ func testCloseCurrentPanelClosesWorkspaceWhenItOwnsTheLastSurface() {
+ let manager = TabManager()
+ let firstWorkspace = manager.tabs[0]
+ let secondWorkspace = manager.addWorkspace()
+ manager.selectWorkspace(secondWorkspace)
+
+ guard let secondPanelId = secondWorkspace.focusedPanelId else {
+ XCTFail("Expected focused panel in selected workspace")
+ return
+ }
+
+ XCTAssertEqual(manager.selectedTabId, secondWorkspace.id)
+ XCTAssertEqual(secondWorkspace.panels.count, 1)
+
+ manager.closeCurrentPanelWithConfirmation()
+ drainMainQueue()
+ drainMainQueue()
+
+ XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id])
+ XCTAssertEqual(manager.selectedTabId, firstWorkspace.id)
+ XCTAssertNil(secondWorkspace.panels[secondPanelId])
+ XCTAssertTrue(secondWorkspace.panels.isEmpty)
+ }
+
+ func testCloseCurrentPanelKeepsWorkspaceOpenWhenKeepWorkspaceOpenPreferenceIsEnabled() {
+ let defaults = UserDefaults.standard
+ let originalSetting = defaults.object(forKey: lastSurfaceCloseShortcutDefaultsKey)
+ defaults.set(false, forKey: lastSurfaceCloseShortcutDefaultsKey)
+ defer {
+ if let originalSetting {
+ defaults.set(originalSetting, forKey: lastSurfaceCloseShortcutDefaultsKey)
+ } else {
+ defaults.removeObject(forKey: lastSurfaceCloseShortcutDefaultsKey)
+ }
+ }
+
+ let manager = TabManager()
+ guard let workspace = manager.selectedWorkspace,
+ let initialPanelId = workspace.focusedPanelId else {
+ XCTFail("Expected selected workspace and focused panel")
+ return
+ }
+
+ let initialWorkspaceId = workspace.id
+
+ manager.closeCurrentPanelWithConfirmation()
+ drainMainQueue()
+ drainMainQueue()
+
+ XCTAssertEqual(manager.tabs.count, 1)
+ XCTAssertEqual(manager.selectedTabId, initialWorkspaceId)
+ XCTAssertEqual(manager.tabs.first?.id, initialWorkspaceId)
+ XCTAssertNil(workspace.panels[initialPanelId])
+ XCTAssertEqual(workspace.panels.count, 1)
+ XCTAssertNotEqual(workspace.focusedPanelId, initialPanelId)
+ }
+
+ func testClosePanelButtonClosesWorkspaceWhenItOwnsTheLastSurface() {
+ let manager = TabManager()
+ let firstWorkspace = manager.tabs[0]
+ let secondWorkspace = manager.addWorkspace()
+ manager.selectWorkspace(secondWorkspace)
+
+ guard let secondPanelId = secondWorkspace.focusedPanelId else {
+ XCTFail("Expected focused panel in selected workspace")
+ return
+ }
+
+ XCTAssertEqual(manager.selectedTabId, secondWorkspace.id)
+ XCTAssertEqual(secondWorkspace.panels.count, 1)
+
+ guard let secondSurfaceId = secondWorkspace.surfaceIdFromPanelId(secondPanelId) else {
+ XCTFail("Expected bonsplit surface ID for focused panel")
+ return
+ }
+
+ secondWorkspace.markExplicitClose(surfaceId: secondSurfaceId)
+ XCTAssertFalse(secondWorkspace.closePanel(secondPanelId))
+ drainMainQueue()
+ drainMainQueue()
+
+ XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id])
+ XCTAssertEqual(manager.selectedTabId, firstWorkspace.id)
+ XCTAssertNil(secondWorkspace.panels[secondPanelId])
+ XCTAssertTrue(secondWorkspace.panels.isEmpty)
+ }
+
+ func testClosePanelButtonStillClosesWorkspaceWhenKeepWorkspaceOpenPreferenceIsEnabled() {
+ let defaults = UserDefaults.standard
+ let originalSetting = defaults.object(forKey: lastSurfaceCloseShortcutDefaultsKey)
+ defaults.set(false, forKey: lastSurfaceCloseShortcutDefaultsKey)
+ defer {
+ if let originalSetting {
+ defaults.set(originalSetting, forKey: lastSurfaceCloseShortcutDefaultsKey)
+ } else {
+ defaults.removeObject(forKey: lastSurfaceCloseShortcutDefaultsKey)
+ }
+ }
+
+ let manager = TabManager()
+ let firstWorkspace = manager.tabs[0]
+ let secondWorkspace = manager.addWorkspace()
+ manager.selectWorkspace(secondWorkspace)
+
+ guard let secondPanelId = secondWorkspace.focusedPanelId else {
+ XCTFail("Expected focused panel in selected workspace")
+ return
+ }
+
+ guard let secondSurfaceId = secondWorkspace.surfaceIdFromPanelId(secondPanelId) else {
+ XCTFail("Expected bonsplit surface ID for focused panel")
+ return
+ }
+
+ secondWorkspace.markExplicitClose(surfaceId: secondSurfaceId)
+ XCTAssertFalse(secondWorkspace.closePanel(secondPanelId))
+ drainMainQueue()
+ drainMainQueue()
+
+ XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id])
+ XCTAssertEqual(manager.selectedTabId, firstWorkspace.id)
+ XCTAssertNil(secondWorkspace.panels[secondPanelId])
+ XCTAssertTrue(secondWorkspace.panels.isEmpty)
+ }
+
+ func testGenericClosePanelKeepsWorkspaceOpenWithoutExplicitCloseMarker() {
+ let manager = TabManager()
+ guard let workspace = manager.selectedWorkspace,
+ let initialPanelId = workspace.focusedPanelId else {
+ XCTFail("Expected selected workspace and focused panel")
+ return
+ }
+
+ let initialWorkspaceId = workspace.id
+ XCTAssertEqual(manager.tabs.count, 1)
+ XCTAssertEqual(workspace.panels.count, 1)
+
+ XCTAssertTrue(workspace.closePanel(initialPanelId))
+ drainMainQueue()
+ drainMainQueue()
+
+ XCTAssertEqual(manager.tabs.count, 1)
+ XCTAssertEqual(manager.selectedTabId, initialWorkspaceId)
+ XCTAssertEqual(manager.tabs.first?.id, initialWorkspaceId)
+ XCTAssertNil(workspace.panels[initialPanelId])
+ XCTAssertEqual(workspace.panels.count, 1)
+ XCTAssertNotEqual(workspace.focusedPanelId, initialPanelId)
+ }
+
+ func testCloseCurrentPanelIgnoresStaleSurfaceId() {
+ let manager = TabManager()
+ let firstWorkspace = manager.tabs[0]
+ let secondWorkspace = manager.addWorkspace()
+
+ manager.closePanelWithConfirmation(tabId: secondWorkspace.id, surfaceId: UUID())
+
+ XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id, secondWorkspace.id])
+ }
+
+ func testCloseCurrentPanelClearsNotificationsForClosedSurface() {
+ let appDelegate = AppDelegate.shared ?? AppDelegate()
+ let manager = TabManager()
+ let store = TerminalNotificationStore.shared
+
+ let originalTabManager = appDelegate.tabManager
+ let originalNotificationStore = appDelegate.notificationStore
+ store.replaceNotificationsForTesting([])
+ store.configureNotificationDeliveryHandlerForTesting { _, _ in }
+ appDelegate.tabManager = manager
+ appDelegate.notificationStore = store
+
+ defer {
+ store.replaceNotificationsForTesting([])
+ store.resetNotificationDeliveryHandlerForTesting()
+ appDelegate.tabManager = originalTabManager
+ appDelegate.notificationStore = originalNotificationStore
+ }
+
+ guard let workspace = manager.selectedWorkspace,
+ let initialPanelId = workspace.focusedPanelId else {
+ XCTFail("Expected selected workspace and focused panel")
+ return
+ }
+
+ store.addNotification(
+ tabId: workspace.id,
+ surfaceId: initialPanelId,
+ title: "Unread",
+ subtitle: "",
+ body: ""
+ )
+ XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: initialPanelId))
+
+ manager.closeCurrentPanelWithConfirmation()
+ drainMainQueue()
+ drainMainQueue()
+
+ XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: initialPanelId))
+ }
+}
+
+
+@MainActor
+final class TabManagerNotificationFocusTests: XCTestCase {
+ func testFocusTabFromNotificationClearsSplitZoomBeforeFocusingTargetPanel() {
+ let manager = TabManager()
+ guard let workspace = manager.selectedWorkspace,
+ let leftPanelId = workspace.focusedPanelId,
+ let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
+ XCTFail("Expected split setup to succeed")
+ return
+ }
+
+ workspace.focusPanel(leftPanelId)
+ XCTAssertTrue(workspace.toggleSplitZoom(panelId: leftPanelId), "Expected split zoom to enable")
+ XCTAssertTrue(workspace.bonsplitController.isSplitZoomed, "Expected workspace to start zoomed")
+
+ XCTAssertTrue(manager.focusTabFromNotification(workspace.id, surfaceId: rightPanel.id))
+ drainMainQueue()
+ drainMainQueue()
+
+ XCTAssertFalse(
+ workspace.bonsplitController.isSplitZoomed,
+ "Expected notification focus to exit split zoom so the target pane becomes visible"
+ )
+ XCTAssertEqual(workspace.focusedPanelId, rightPanel.id, "Expected notification target panel to be focused")
+ }
+
+ func testFocusTabFromNotificationReturnsFalseForMissingPanel() {
+ let manager = TabManager()
+ guard let workspace = manager.selectedWorkspace else {
+ XCTFail("Expected selected workspace")
+ return
+ }
+
+ XCTAssertFalse(manager.focusTabFromNotification(workspace.id, surfaceId: UUID()))
+ }
+}
+
+
+@MainActor
+final class TabManagerPendingUnfocusPolicyTests: XCTestCase {
+ func testDoesNotUnfocusWhenPendingTabIsCurrentlySelected() {
+ let tabId = UUID()
+
+ XCTAssertFalse(
+ TabManager.shouldUnfocusPendingWorkspace(
+ pendingTabId: tabId,
+ selectedTabId: tabId
+ )
+ )
+ }
+
+ func testUnfocusesWhenPendingTabIsNotSelected() {
+ XCTAssertTrue(
+ TabManager.shouldUnfocusPendingWorkspace(
+ pendingTabId: UUID(),
+ selectedTabId: UUID()
+ )
+ )
+ XCTAssertTrue(
+ TabManager.shouldUnfocusPendingWorkspace(
+ pendingTabId: UUID(),
+ selectedTabId: nil
+ )
+ )
+ }
+}
+
+
+@MainActor
+final class TabManagerSurfaceCreationTests: XCTestCase {
+ func testNewSurfaceFocusesCreatedSurface() {
+ let manager = TabManager()
+ guard let workspace = manager.selectedWorkspace else {
+ XCTFail("Expected a selected workspace")
+ return
+ }
+
+ let beforePanels = Set(workspace.panels.keys)
+ manager.newSurface()
+ let afterPanels = Set(workspace.panels.keys)
+
+ let createdPanels = afterPanels.subtracting(beforePanels)
+ XCTAssertEqual(createdPanels.count, 1, "Expected one new surface for Cmd+T path")
+ guard let createdPanelId = createdPanels.first else { return }
+
+ XCTAssertEqual(
+ workspace.focusedPanelId,
+ createdPanelId,
+ "Expected newly created surface to be focused"
+ )
+ }
+
+ func testOpenBrowserInsertAtEndPlacesNewBrowserAtPaneEnd() {
+ let manager = TabManager()
+ guard let workspace = manager.selectedWorkspace,
+ let paneId = workspace.bonsplitController.focusedPaneId else {
+ XCTFail("Expected focused workspace and pane")
+ return
+ }
+
+ // Add one extra surface so we verify append-to-end rather than first insert behavior.
+ _ = workspace.newTerminalSurface(inPane: paneId, focus: false)
+
+ guard let browserPanelId = manager.openBrowser(insertAtEnd: true) else {
+ XCTFail("Expected browser panel to be created")
+ return
+ }
+
+ let tabs = workspace.bonsplitController.tabs(inPane: paneId)
+ guard let lastSurfaceId = tabs.last?.id else {
+ XCTFail("Expected at least one surface in pane")
+ return
+ }
+
+ XCTAssertEqual(
+ workspace.panelIdFromSurfaceId(lastSurfaceId),
+ browserPanelId,
+ "Expected Cmd+Shift+B/Cmd+L open path to append browser surface at end"
+ )
+ XCTAssertEqual(workspace.focusedPanelId, browserPanelId, "Expected opened browser surface to be focused")
+ }
+
+ func testOpenBrowserInWorkspaceSplitRightSelectsTargetWorkspaceAndCreatesSplit() {
+ let manager = TabManager()
+ guard let initialWorkspace = manager.selectedWorkspace else {
+ XCTFail("Expected initial selected workspace")
+ return
+ }
+ guard let url = URL(string: "https://example.com/pull/123") else {
+ XCTFail("Expected test URL to be valid")
+ return
+ }
+
+ let targetWorkspace = manager.addWorkspace(select: false)
+ manager.selectWorkspace(initialWorkspace)
+ let initialPaneCount = targetWorkspace.bonsplitController.allPaneIds.count
+ let initialPanelCount = targetWorkspace.panels.count
+
+ guard let browserPanelId = manager.openBrowser(
+ inWorkspace: targetWorkspace.id,
+ url: url,
+ preferSplitRight: true,
+ insertAtEnd: true
+ ) else {
+ XCTFail("Expected browser panel to be created in target workspace")
+ return
+ }
+
+ XCTAssertEqual(manager.selectedTabId, targetWorkspace.id, "Expected target workspace to become selected")
+ XCTAssertEqual(
+ targetWorkspace.bonsplitController.allPaneIds.count,
+ initialPaneCount + 1,
+ "Expected split-right browser open to create a new pane"
+ )
+ XCTAssertEqual(
+ targetWorkspace.panels.count,
+ initialPanelCount + 1,
+ "Expected browser panel count to increase by one"
+ )
+ XCTAssertEqual(
+ targetWorkspace.focusedPanelId,
+ browserPanelId,
+ "Expected created browser panel to be focused in target workspace"
+ )
+ XCTAssertTrue(
+ targetWorkspace.panels[browserPanelId] is BrowserPanel,
+ "Expected created panel to be a browser panel"
+ )
+ }
+
+ func testOpenBrowserInWorkspaceSplitRightReusesTopRightPaneWhenAlreadySplit() {
+ let manager = TabManager()
+ guard let workspace = manager.selectedWorkspace,
+ let leftPanelId = workspace.focusedPanelId,
+ let topRightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal),
+ workspace.newTerminalSplit(from: topRightPanel.id, orientation: .vertical) != nil,
+ let topRightPaneId = workspace.paneId(forPanelId: topRightPanel.id),
+ let url = URL(string: "https://example.com/pull/456") else {
+ XCTFail("Expected split setup to succeed")
+ return
+ }
+
+ let initialPaneCount = workspace.bonsplitController.allPaneIds.count
+
+ guard let browserPanelId = manager.openBrowser(
+ inWorkspace: workspace.id,
+ url: url,
+ preferSplitRight: true,
+ insertAtEnd: true
+ ) else {
+ XCTFail("Expected browser panel to be created")
+ return
+ }
+
+ XCTAssertEqual(
+ workspace.bonsplitController.allPaneIds.count,
+ initialPaneCount,
+ "Expected split-right browser open to reuse existing panes"
+ )
+ XCTAssertEqual(
+ workspace.paneId(forPanelId: browserPanelId),
+ topRightPaneId,
+ "Expected browser to open in the top-right pane when multiple splits already exist"
+ )
+
+ let targetPaneTabs = workspace.bonsplitController.tabs(inPane: topRightPaneId)
+ guard let lastSurfaceId = targetPaneTabs.last?.id else {
+ XCTFail("Expected top-right pane to contain tabs")
+ return
+ }
+ XCTAssertEqual(
+ workspace.panelIdFromSurfaceId(lastSurfaceId),
+ browserPanelId,
+ "Expected browser surface to be appended at end in the reused top-right pane"
+ )
+ }
+}
+
+
+@MainActor
+final class TabManagerEqualizeSplitsTests: XCTestCase {
+ func testEqualizeSplitsSetsEverySplitDividerToHalf() {
+ let manager = TabManager()
+ guard let workspace = manager.selectedWorkspace,
+ let leftPanelId = workspace.focusedPanelId,
+ let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal),
+ workspace.newTerminalSplit(from: rightPanel.id, orientation: .vertical) != nil else {
+ XCTFail("Expected nested split setup to succeed")
+ return
+ }
+
+ let initialSplits = splitNodes(in: workspace.bonsplitController.treeSnapshot())
+ XCTAssertGreaterThanOrEqual(initialSplits.count, 2, "Expected at least two split nodes in nested layout")
+
+ for (index, split) in initialSplits.enumerated() {
+ guard let splitId = UUID(uuidString: split.id) else {
+ XCTFail("Expected split ID to be a UUID")
+ return
+ }
+ let targetPosition: CGFloat = index.isMultiple(of: 2) ? 0.2 : 0.8
+ XCTAssertTrue(
+ workspace.bonsplitController.setDividerPosition(targetPosition, forSplit: splitId),
+ "Expected to seed divider position for split \(splitId)"
+ )
+ }
+
+ XCTAssertTrue(manager.equalizeSplits(tabId: workspace.id), "Expected equalize splits command to succeed")
+
+ let equalizedSplits = splitNodes(in: workspace.bonsplitController.treeSnapshot())
+ XCTAssertEqual(equalizedSplits.count, initialSplits.count)
+ for split in equalizedSplits {
+ XCTAssertEqual(split.dividerPosition, 0.5, accuracy: 0.000_1)
+ }
+ }
+
+ private func splitNodes(in node: ExternalTreeNode) -> [ExternalSplitNode] {
+ switch node {
+ case .pane:
+ return []
+ case .split(let split):
+ return [split] + splitNodes(in: split.first) + splitNodes(in: split.second)
+ }
+ }
+}
+
+
+@MainActor
+final class TabManagerWorkspaceConfigInheritanceSourceTests: XCTestCase {
+ func testUsesFocusedTerminalWhenTerminalIsFocused() {
+ let manager = TabManager()
+ guard let workspace = manager.selectedWorkspace,
+ let terminalPanelId = workspace.focusedPanelId else {
+ XCTFail("Expected selected workspace with focused terminal")
+ return
+ }
+
+ let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource()
+ XCTAssertEqual(sourcePanel?.id, terminalPanelId)
+ }
+
+ func testFallsBackToTerminalWhenBrowserIsFocused() {
+ let manager = TabManager()
+ guard let workspace = manager.selectedWorkspace,
+ let terminalPanelId = workspace.focusedPanelId,
+ let paneId = workspace.paneId(forPanelId: terminalPanelId),
+ let browserPanel = workspace.newBrowserSurface(inPane: paneId, focus: true) else {
+ XCTFail("Expected selected workspace setup to succeed")
+ return
+ }
+
+ XCTAssertEqual(workspace.focusedPanelId, browserPanel.id)
+
+ let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource()
+ XCTAssertEqual(
+ sourcePanel?.id,
+ terminalPanelId,
+ "Expected new workspace inheritance source to resolve to the pane terminal when browser is focused"
+ )
+ }
+
+ func testPrefersLastFocusedTerminalAcrossPanesWhenBrowserIsFocused() {
+ let manager = TabManager()
+ guard let workspace = manager.selectedWorkspace,
+ let leftTerminalPanelId = workspace.focusedPanelId,
+ let rightTerminalPanel = workspace.newTerminalSplit(from: leftTerminalPanelId, orientation: .horizontal),
+ let rightPaneId = workspace.paneId(forPanelId: rightTerminalPanel.id) else {
+ XCTFail("Expected split setup to succeed")
+ return
+ }
+
+ workspace.focusPanel(leftTerminalPanelId)
+ _ = workspace.newBrowserSurface(inPane: rightPaneId, focus: true)
+ XCTAssertNotEqual(workspace.focusedPanelId, leftTerminalPanelId)
+
+ let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource()
+ XCTAssertEqual(
+ sourcePanel?.id,
+ leftTerminalPanelId,
+ "Expected workspace inheritance source to use last focused terminal across panes"
+ )
+ }
+}
+
+
+@MainActor
+final class TabManagerReopenClosedBrowserFocusTests: XCTestCase {
+ func testReopenFromDifferentWorkspaceFocusesReopenedBrowser() {
+ let manager = TabManager()
+ guard let workspace1 = manager.selectedWorkspace,
+ let closedBrowserId = manager.openBrowser(url: URL(string: "https://example.com/ws-switch")) else {
+ XCTFail("Expected initial workspace and browser panel")
+ return
+ }
+
+ drainMainQueue()
+ XCTAssertTrue(workspace1.closePanel(closedBrowserId, force: true))
+ drainMainQueue()
+
+ let workspace2 = manager.addWorkspace()
+ XCTAssertEqual(manager.selectedTabId, workspace2.id)
+
+ XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel())
+ drainMainQueue()
+
+ XCTAssertEqual(manager.selectedTabId, workspace1.id)
+ XCTAssertTrue(isFocusedPanelBrowser(in: workspace1))
+ }
+
+ func testReopenFallsBackToCurrentWorkspaceAndFocusesBrowserWhenOriginalWorkspaceDeleted() {
+ let manager = TabManager()
+ guard let originalWorkspace = manager.selectedWorkspace,
+ let closedBrowserId = manager.openBrowser(url: URL(string: "https://example.com/deleted-ws")) else {
+ XCTFail("Expected initial workspace and browser panel")
+ return
+ }
+
+ drainMainQueue()
+ XCTAssertTrue(originalWorkspace.closePanel(closedBrowserId, force: true))
+ drainMainQueue()
+
+ let currentWorkspace = manager.addWorkspace()
+ manager.closeWorkspace(originalWorkspace)
+
+ XCTAssertEqual(manager.selectedTabId, currentWorkspace.id)
+ XCTAssertFalse(manager.tabs.contains(where: { $0.id == originalWorkspace.id }))
+
+ XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel())
+ drainMainQueue()
+
+ XCTAssertEqual(manager.selectedTabId, currentWorkspace.id)
+ XCTAssertTrue(isFocusedPanelBrowser(in: currentWorkspace))
+ }
+
+ func testReopenCollapsedSplitFromDifferentWorkspaceFocusesBrowser() {
+ let manager = TabManager()
+ guard let workspace1 = manager.selectedWorkspace,
+ let sourcePanelId = workspace1.focusedPanelId,
+ let splitBrowserId = manager.newBrowserSplit(
+ tabId: workspace1.id,
+ fromPanelId: sourcePanelId,
+ orientation: .horizontal,
+ insertFirst: false,
+ url: URL(string: "https://example.com/collapsed-split")
+ ) else {
+ XCTFail("Expected to create browser split")
+ return
+ }
+
+ drainMainQueue()
+ XCTAssertTrue(workspace1.closePanel(splitBrowserId, force: true))
+ drainMainQueue()
+
+ let workspace2 = manager.addWorkspace()
+ XCTAssertEqual(manager.selectedTabId, workspace2.id)
+
+ XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel())
+ drainMainQueue()
+
+ XCTAssertEqual(manager.selectedTabId, workspace1.id)
+ XCTAssertTrue(isFocusedPanelBrowser(in: workspace1))
+ }
+
+ func testReopenFromDifferentWorkspaceWinsAgainstSingleDeferredStaleFocus() {
+ let manager = TabManager()
+ guard let workspace1 = manager.selectedWorkspace,
+ let preReopenPanelId = workspace1.focusedPanelId,
+ let closedBrowserId = manager.openBrowser(url: URL(string: "https://example.com/stale-focus-cross-ws")) else {
+ XCTFail("Expected initial workspace state and browser panel")
+ return
+ }
+
+ drainMainQueue()
+ XCTAssertTrue(workspace1.closePanel(closedBrowserId, force: true))
+ drainMainQueue()
+
+ let panelIdsBeforeReopen = Set(workspace1.panels.keys)
+ let workspace2 = manager.addWorkspace()
+ XCTAssertEqual(manager.selectedTabId, workspace2.id)
+
+ XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel())
+ guard let reopenedPanelId = singleNewPanelId(in: workspace1, comparedTo: panelIdsBeforeReopen) else {
+ XCTFail("Expected reopened browser panel ID")
+ return
+ }
+
+ // Simulate one delayed stale focus callback from the panel that was focused before reopen.
+ DispatchQueue.main.async {
+ workspace1.focusPanel(preReopenPanelId)
+ }
+
+ drainMainQueue()
+ drainMainQueue()
+ drainMainQueue()
+
+ XCTAssertEqual(manager.selectedTabId, workspace1.id)
+ XCTAssertEqual(workspace1.focusedPanelId, reopenedPanelId)
+ XCTAssertTrue(workspace1.panels[reopenedPanelId] is BrowserPanel)
+ }
+
+ func testReopenInSameWorkspaceWinsAgainstSingleDeferredStaleFocus() {
+ let manager = TabManager()
+ guard let workspace = manager.selectedWorkspace,
+ let preReopenPanelId = workspace.focusedPanelId,
+ let closedBrowserId = manager.openBrowser(url: URL(string: "https://example.com/stale-focus-same-ws")) else {
+ XCTFail("Expected initial workspace state and browser panel")
+ return
+ }
+
+ drainMainQueue()
+ XCTAssertTrue(workspace.closePanel(closedBrowserId, force: true))
+ drainMainQueue()
+
+ let panelIdsBeforeReopen = Set(workspace.panels.keys)
+ XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel())
+ guard let reopenedPanelId = singleNewPanelId(in: workspace, comparedTo: panelIdsBeforeReopen) else {
+ XCTFail("Expected reopened browser panel ID")
+ return
+ }
+
+ // Simulate one delayed stale focus callback from the panel that was focused before reopen.
+ DispatchQueue.main.async {
+ workspace.focusPanel(preReopenPanelId)
+ }
+
+ drainMainQueue()
+ drainMainQueue()
+ drainMainQueue()
+
+ XCTAssertEqual(manager.selectedTabId, workspace.id)
+ XCTAssertEqual(workspace.focusedPanelId, reopenedPanelId)
+ XCTAssertTrue(workspace.panels[reopenedPanelId] is BrowserPanel)
+ }
+
+ private func isFocusedPanelBrowser(in workspace: Workspace) -> Bool {
+ guard let focusedPanelId = workspace.focusedPanelId else { return false }
+ return workspace.panels[focusedPanelId] is BrowserPanel
+ }
+
+ private func singleNewPanelId(in workspace: Workspace, comparedTo previousPanelIds: Set) -> UUID? {
+ let newPanelIds = Set(workspace.panels.keys).subtracting(previousPanelIds)
+ guard newPanelIds.count == 1 else { return nil }
+ return newPanelIds.first
+ }
+
+ private func drainMainQueue() {
+ let expectation = expectation(description: "drain main queue")
+ DispatchQueue.main.async {
+ expectation.fulfill()
+ }
+ wait(for: [expectation], timeout: 1.0)
+ }
+}
diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift
new file mode 100644
index 00000000..272d8b5a
--- /dev/null
+++ b/cmuxTests/TerminalAndGhosttyTests.swift
@@ -0,0 +1,2578 @@
+import XCTest
+import AppKit
+import SwiftUI
+import UniformTypeIdentifiers
+import WebKit
+import ObjectiveC.runtime
+import Bonsplit
+import UserNotifications
+
+#if canImport(cmux_DEV)
+@testable import cmux_DEV
+#elseif canImport(cmux)
+@testable import cmux
+#endif
+
+@MainActor
+final class GhosttyPasteboardHelperTests: XCTestCase {
+ func testHTMLOnlyPasteboardExtractsPlainText() {
+ let pasteboard = NSPasteboard(name: .init("cmux-test-html-\(UUID().uuidString)"))
+ pasteboard.clearContents()
+ pasteboard.setString("Hello world
", forType: .html)
+
+ XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello world")
+ XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard))
+ }
+
+ func testImageHTMLClipboardFallsBackToImagePath() throws {
+ let pasteboard = NSPasteboard(name: .init("cmux-test-image-html-\(UUID().uuidString)"))
+ pasteboard.clearContents()
+ pasteboard.setString("
", forType: .html)
+
+ let image = NSImage(size: NSSize(width: 1, height: 1))
+ image.lockFocus()
+ NSColor.red.setFill()
+ NSRect(x: 0, y: 0, width: 1, height: 1).fill()
+ image.unlockFocus()
+ let tiffData = try XCTUnwrap(image.tiffRepresentation)
+ let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData))
+ let pngData = try XCTUnwrap(bitmap.representation(using: .png, properties: [:]))
+ pasteboard.setData(pngData, forType: .png)
+
+ XCTAssertNil(cmuxPasteboardStringContentsForTesting(pasteboard))
+
+ let imagePath = try XCTUnwrap(cmuxPasteboardImagePathForTesting(pasteboard))
+ defer { try? FileManager.default.removeItem(atPath: imagePath) }
+
+ XCTAssertTrue(imagePath.hasSuffix(".png"))
+ XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath))
+ }
+
+ func testImageHTMLClipboardWithVisibleTextPrefersText() throws {
+ let pasteboard = NSPasteboard(name: .init("cmux-test-image-html-text-\(UUID().uuidString)"))
+ pasteboard.clearContents()
+ pasteboard.setString("Hello 
", forType: .html)
+
+ let image = NSImage(size: NSSize(width: 1, height: 1))
+ image.lockFocus()
+ NSColor.blue.setFill()
+ NSRect(x: 0, y: 0, width: 1, height: 1).fill()
+ image.unlockFocus()
+ let tiffData = try XCTUnwrap(image.tiffRepresentation)
+ let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData))
+ let pngData = try XCTUnwrap(bitmap.representation(using: .png, properties: [:]))
+ pasteboard.setData(pngData, forType: .png)
+
+ XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello")
+ XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard))
+ }
+
+ func testJPEGClipboardFallsBackToImagePath() throws {
+ let pasteboard = NSPasteboard(name: .init("cmux-test-jpeg-\(UUID().uuidString)"))
+ pasteboard.clearContents()
+
+ let image = NSImage(size: NSSize(width: 1, height: 1))
+ image.lockFocus()
+ NSColor.green.setFill()
+ NSRect(x: 0, y: 0, width: 1, height: 1).fill()
+ image.unlockFocus()
+
+ let tiffData = try XCTUnwrap(image.tiffRepresentation)
+ let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData))
+ let jpegData = try XCTUnwrap(
+ bitmap.representation(
+ using: .jpeg,
+ properties: [.compressionFactor: 1.0]
+ )
+ )
+ pasteboard.setData(
+ jpegData,
+ forType: NSPasteboard.PasteboardType(UTType.jpeg.identifier)
+ )
+
+ let imagePath = try XCTUnwrap(cmuxPasteboardImagePathForTesting(pasteboard))
+ defer { try? FileManager.default.removeItem(atPath: imagePath) }
+
+ XCTAssertTrue(imagePath.hasSuffix(".jpeg"))
+ XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath))
+ }
+
+ func testAttachmentOnlyRTFDClipboardFallsBackToImagePath() throws {
+ let pasteboard = NSPasteboard(name: .init("cmux-test-rtfd-attachment-\(UUID().uuidString)"))
+ pasteboard.clearContents()
+
+ let image = NSImage(size: NSSize(width: 1, height: 1))
+ image.lockFocus()
+ NSColor.orange.setFill()
+ NSRect(x: 0, y: 0, width: 1, height: 1).fill()
+ image.unlockFocus()
+
+ let attachment = NSTextAttachment()
+ attachment.image = image
+ let attributed = NSAttributedString(attachment: attachment)
+ let data = try attributed.data(
+ from: NSRange(location: 0, length: attributed.length),
+ documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd]
+ )
+ pasteboard.setData(data, forType: .rtfd)
+
+ XCTAssertNil(cmuxPasteboardStringContentsForTesting(pasteboard))
+
+ let imagePath = try XCTUnwrap(cmuxPasteboardImagePathForTesting(pasteboard))
+ defer { try? FileManager.default.removeItem(atPath: imagePath) }
+
+ XCTAssertTrue(imagePath.hasSuffix(".tiff"))
+ XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath))
+ }
+
+ func testAttachmentOnlyRTFDNonImageClipboardDoesNotFallBackToImagePath() throws {
+ let pasteboard = NSPasteboard(name: .init("cmux-test-rtfd-non-image-\(UUID().uuidString)"))
+ pasteboard.clearContents()
+
+ let wrapper = FileWrapper(regularFileWithContents: Data("hello".utf8))
+ wrapper.preferredFilename = "note.txt"
+
+ let attachment = NSTextAttachment(fileWrapper: wrapper)
+ let attributed = NSAttributedString(attachment: attachment)
+ let data = try attributed.data(
+ from: NSRange(location: 0, length: attributed.length),
+ documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd]
+ )
+ pasteboard.setData(data, forType: .rtfd)
+
+ XCTAssertNil(cmuxPasteboardStringContentsForTesting(pasteboard))
+ XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard))
+ }
+
+ func testRTFDClipboardWithVisibleTextPrefersText() throws {
+ let pasteboard = NSPasteboard(name: .init("cmux-test-rtfd-text-\(UUID().uuidString)"))
+ pasteboard.clearContents()
+
+ let image = NSImage(size: NSSize(width: 1, height: 1))
+ image.lockFocus()
+ NSColor.purple.setFill()
+ NSRect(x: 0, y: 0, width: 1, height: 1).fill()
+ image.unlockFocus()
+
+ let attachment = NSTextAttachment()
+ attachment.image = image
+
+ let attributed = NSMutableAttributedString(string: "Hello ")
+ attributed.append(NSAttributedString(attachment: attachment))
+ let data = try attributed.data(
+ from: NSRange(location: 0, length: attributed.length),
+ documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd]
+ )
+ pasteboard.setData(data, forType: .rtfd)
+
+ XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello")
+ XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard))
+ }
+}
+
+
+final class TerminalKeyboardCopyModeActionTests: XCTestCase {
+ func testCopyModeBypassAllowsOnlyCommandShortcuts() {
+ XCTAssertTrue(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.command]))
+ XCTAssertTrue(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.command, .shift]))
+ XCTAssertTrue(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.command, .option]))
+ XCTAssertFalse(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.option]))
+ XCTAssertFalse(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.option, .shift]))
+ XCTAssertFalse(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.control]))
+ }
+
+ func testJKWithoutSelectionScrollByLine() {
+ XCTAssertEqual(
+ terminalKeyboardCopyModeAction(
+ keyCode: 38,
+ charactersIgnoringModifiers: "j",
+ modifierFlags: [],
+ hasSelection: false
+ ),
+ .scrollLines(1)
+ )
+ XCTAssertEqual(
+ terminalKeyboardCopyModeAction(
+ keyCode: 40,
+ charactersIgnoringModifiers: "k",
+ modifierFlags: [],
+ hasSelection: false
+ ),
+ .scrollLines(-1)
+ )
+ }
+
+ func testCapsLockDoesNotBlockLetterMappings() {
+ XCTAssertEqual(
+ terminalKeyboardCopyModeAction(
+ keyCode: 38,
+ charactersIgnoringModifiers: "j",
+ modifierFlags: [.capsLock],
+ hasSelection: false
+ ),
+ .scrollLines(1)
+ )
+ }
+
+ func testJKWithSelectionAdjustSelection() {
+ XCTAssertEqual(
+ terminalKeyboardCopyModeAction(
+ keyCode: 38,
+ charactersIgnoringModifiers: "j",
+ modifierFlags: [],
+ hasSelection: true
+ ),
+ .adjustSelection(.down)
+ )
+ XCTAssertEqual(
+ terminalKeyboardCopyModeAction(
+ keyCode: 40,
+ charactersIgnoringModifiers: "k",
+ modifierFlags: [],
+ hasSelection: true
+ ),
+ .adjustSelection(.up)
+ )
+ }
+
+ func testControlPagingSupportsPrintableAndControlCharacters() {
+ // Ctrl+U = half-page up (vim standard).
+ XCTAssertEqual(
+ terminalKeyboardCopyModeAction(
+ keyCode: 0,
+ charactersIgnoringModifiers: "\u{15}",
+ modifierFlags: [.control],
+ hasSelection: false
+ ),
+ .scrollHalfPage(-1)
+ )
+ XCTAssertEqual(
+ terminalKeyboardCopyModeAction(
+ keyCode: 0,
+ charactersIgnoringModifiers: "\u{04}",
+ modifierFlags: [.control],
+ hasSelection: true
+ ),
+ .adjustSelection(.pageDown)
+ )
+ XCTAssertEqual(
+ terminalKeyboardCopyModeAction(
+ keyCode: 0,
+ charactersIgnoringModifiers: "\u{02}",
+ modifierFlags: [.control],
+ hasSelection: false
+ ),
+ .scrollPage(-1)
+ )
+ XCTAssertEqual(
+ terminalKeyboardCopyModeAction(
+ keyCode: 0,
+ charactersIgnoringModifiers: "\u{06}",
+ modifierFlags: [.control],
+ hasSelection: true
+ ),
+ .adjustSelection(.pageDown)
+ )
+ XCTAssertEqual(
+ terminalKeyboardCopyModeAction(
+ keyCode: 0,
+ charactersIgnoringModifiers: "\u{19}",
+ modifierFlags: [.control],
+ hasSelection: false
+ ),
+ .scrollLines(-1)
+ )
+ XCTAssertEqual(
+ terminalKeyboardCopyModeAction(
+ keyCode: 0,
+ charactersIgnoringModifiers: "\u{05}",
+ modifierFlags: [.control],
+ hasSelection: true
+ ),
+ .adjustSelection(.down)
+ )
+ }
+
+ func testVGYMapping() {
+ XCTAssertEqual(
+ terminalKeyboardCopyModeAction(
+ keyCode: 9,
+ charactersIgnoringModifiers: "v",
+ modifierFlags: [],
+ hasSelection: false
+ ),
+ .startSelection
+ )
+ XCTAssertEqual(
+ terminalKeyboardCopyModeAction(
+ keyCode: 9,
+ charactersIgnoringModifiers: "v",
+ modifierFlags: [],
+ hasSelection: true
+ ),
+ .clearSelection
+ )
+ XCTAssertEqual(
+ terminalKeyboardCopyModeAction(
+ keyCode: 16,
+ charactersIgnoringModifiers: "y",
+ modifierFlags: [],
+ hasSelection: true
+ ),
+ .copyAndExit
+ )
+ }
+
+ func testGAndShiftGMapping() {
+ // Bare "g" is a prefix key (gg), not an immediate action.
+ XCTAssertNil(
+ terminalKeyboardCopyModeAction(
+ keyCode: 5,
+ charactersIgnoringModifiers: "g",
+ modifierFlags: [],
+ hasSelection: false
+ )
+ )
+ XCTAssertEqual(
+ terminalKeyboardCopyModeAction(
+ keyCode: 5,
+ charactersIgnoringModifiers: "g",
+ modifierFlags: [.shift],
+ hasSelection: false
+ ),
+ .scrollToBottom
+ )
+ }
+
+ func testLineBoundaryPromptAndSearchMappings() {
+ XCTAssertEqual(
+ terminalKeyboardCopyModeAction(
+ keyCode: 29,
+ charactersIgnoringModifiers: "0",
+ modifierFlags: [],
+ hasSelection: true
+ ),
+ .adjustSelection(.beginningOfLine)
+ )
+ XCTAssertEqual(
+ terminalKeyboardCopyModeAction(
+ keyCode: 20,
+ charactersIgnoringModifiers: "^",
+ modifierFlags: [.shift],
+ hasSelection: true
+ ),
+ .adjustSelection(.beginningOfLine)
+ )
+ XCTAssertEqual(
+ terminalKeyboardCopyModeAction(
+ keyCode: 21,
+ charactersIgnoringModifiers: "4",
+ modifierFlags: [.shift],
+ hasSelection: true
+ ),
+ .adjustSelection(.endOfLine)
+ )
+ XCTAssertEqual(
+ terminalKeyboardCopyModeAction(
+ keyCode: 33,
+ charactersIgnoringModifiers: "[",
+ modifierFlags: [.shift],
+ hasSelection: false
+ ),
+ .jumpToPrompt(-1)
+ )
+ XCTAssertEqual(
+ terminalKeyboardCopyModeAction(
+ keyCode: 30,
+ charactersIgnoringModifiers: "]",
+ modifierFlags: [.shift],
+ hasSelection: false
+ ),
+ .jumpToPrompt(1)
+ )
+ XCTAssertNil(
+ terminalKeyboardCopyModeAction(
+ keyCode: 21,
+ charactersIgnoringModifiers: "4",
+ modifierFlags: [],
+ hasSelection: true
+ )
+ )
+ XCTAssertNil(
+ terminalKeyboardCopyModeAction(
+ keyCode: 33,
+ charactersIgnoringModifiers: "[",
+ modifierFlags: [],
+ hasSelection: false
+ )
+ )
+ XCTAssertNil(
+ terminalKeyboardCopyModeAction(
+ keyCode: 30,
+ charactersIgnoringModifiers: "]",
+ modifierFlags: [],
+ hasSelection: false
+ )
+ )
+ XCTAssertEqual(
+ terminalKeyboardCopyModeAction(
+ keyCode: 44,
+ charactersIgnoringModifiers: "/",
+ modifierFlags: [],
+ hasSelection: false
+ ),
+ .startSearch
+ )
+ XCTAssertEqual(
+ terminalKeyboardCopyModeAction(
+ keyCode: 45,
+ charactersIgnoringModifiers: "n",
+ modifierFlags: [],
+ hasSelection: false
+ ),
+ .searchNext
+ )
+ XCTAssertEqual(
+ terminalKeyboardCopyModeAction(
+ keyCode: 45,
+ charactersIgnoringModifiers: "n",
+ modifierFlags: [.shift],
+ hasSelection: false
+ ),
+ .searchPrevious
+ )
+ }
+
+ func testShiftVMatchesVisualToggleBehavior() {
+ XCTAssertEqual(
+ terminalKeyboardCopyModeAction(
+ keyCode: 9,
+ charactersIgnoringModifiers: "v",
+ modifierFlags: [.shift],
+ hasSelection: false
+ ),
+ .startSelection
+ )
+ XCTAssertEqual(
+ terminalKeyboardCopyModeAction(
+ keyCode: 9,
+ charactersIgnoringModifiers: "v",
+ modifierFlags: [.shift],
+ hasSelection: true
+ ),
+ .clearSelection
+ )
+ }
+
+ func testEscapeAlwaysExits() {
+ XCTAssertEqual(
+ terminalKeyboardCopyModeAction(
+ keyCode: 53,
+ charactersIgnoringModifiers: "",
+ modifierFlags: [],
+ hasSelection: false
+ ),
+ .exit
+ )
+ }
+
+ func testQAlwaysExits() {
+ XCTAssertEqual(
+ terminalKeyboardCopyModeAction(
+ keyCode: 12, // kVK_ANSI_Q
+ charactersIgnoringModifiers: "q",
+ modifierFlags: [],
+ hasSelection: false
+ ),
+ .exit
+ )
+ }
+}
+
+
+final class TerminalKeyboardCopyModeResolveTests: XCTestCase {
+ private func resolve(
+ _ keyCode: UInt16,
+ chars: String,
+ modifiers: NSEvent.ModifierFlags = [],
+ hasSelection: Bool,
+ state: inout TerminalKeyboardCopyModeInputState
+ ) -> TerminalKeyboardCopyModeResolution {
+ terminalKeyboardCopyModeResolve(
+ keyCode: keyCode,
+ charactersIgnoringModifiers: chars,
+ modifierFlags: modifiers,
+ hasSelection: hasSelection,
+ state: &state
+ )
+ }
+
+ func testCountPrefixAppliesToMotion() {
+ var state = TerminalKeyboardCopyModeInputState()
+ XCTAssertEqual(resolve(20, chars: "3", hasSelection: false, state: &state), .consume)
+ XCTAssertEqual(resolve(38, chars: "j", hasSelection: false, state: &state), .perform(.scrollLines(1), count: 3))
+ XCTAssertEqual(state, TerminalKeyboardCopyModeInputState())
+ }
+
+ func testZeroAppendsCountOrActsAsMotion() {
+ var state = TerminalKeyboardCopyModeInputState()
+ XCTAssertEqual(resolve(19, chars: "2", hasSelection: false, state: &state), .consume)
+ XCTAssertEqual(resolve(29, chars: "0", hasSelection: false, state: &state), .consume)
+ XCTAssertEqual(resolve(40, chars: "k", hasSelection: false, state: &state), .perform(.scrollLines(-1), count: 20))
+
+ var selectionState = TerminalKeyboardCopyModeInputState()
+ XCTAssertEqual(
+ resolve(29, chars: "0", hasSelection: true, state: &selectionState),
+ .perform(.adjustSelection(.beginningOfLine), count: 1)
+ )
+ }
+
+ func testYankLineOperatorSupportsYYAndYWithCounts() {
+ var yyState = TerminalKeyboardCopyModeInputState()
+ XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &yyState), .consume)
+ XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &yyState), .perform(.copyLineAndExit, count: 1))
+
+ var countedState = TerminalKeyboardCopyModeInputState()
+ XCTAssertEqual(resolve(21, chars: "4", hasSelection: false, state: &countedState), .consume)
+ XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &countedState), .consume)
+ XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &countedState), .perform(.copyLineAndExit, count: 4))
+
+ var shiftYState = TerminalKeyboardCopyModeInputState()
+ XCTAssertEqual(resolve(20, chars: "3", hasSelection: false, state: &shiftYState), .consume)
+ XCTAssertEqual(
+ resolve(16, chars: "y", modifiers: [.shift], hasSelection: false, state: &shiftYState),
+ .perform(.copyLineAndExit, count: 3)
+ )
+ }
+
+ func testPendingYankLineDoesNotSwallowNextCommand() {
+ var state = TerminalKeyboardCopyModeInputState()
+ XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &state), .consume)
+ XCTAssertEqual(resolve(38, chars: "j", hasSelection: false, state: &state), .perform(.scrollLines(1), count: 1))
+ XCTAssertEqual(state, TerminalKeyboardCopyModeInputState())
+ }
+
+ func testSearchAndPromptMotionsUseCounts() {
+ var promptState = TerminalKeyboardCopyModeInputState()
+ XCTAssertEqual(resolve(20, chars: "3", hasSelection: false, state: &promptState), .consume)
+ XCTAssertEqual(
+ resolve(30, chars: "]", modifiers: [.shift], hasSelection: false, state: &promptState),
+ .perform(.jumpToPrompt(1), count: 3)
+ )
+
+ var searchState = TerminalKeyboardCopyModeInputState()
+ XCTAssertEqual(resolve(18, chars: "2", hasSelection: false, state: &searchState), .consume)
+ XCTAssertEqual(resolve(45, chars: "n", hasSelection: false, state: &searchState), .perform(.searchNext, count: 2))
+ }
+
+ func testInvalidKeyClearsPendingState() {
+ var state = TerminalKeyboardCopyModeInputState()
+ XCTAssertEqual(resolve(18, chars: "2", hasSelection: false, state: &state), .consume)
+ XCTAssertEqual(resolve(7, chars: "x", hasSelection: false, state: &state), .consume)
+ XCTAssertEqual(state, TerminalKeyboardCopyModeInputState())
+ }
+
+ // MARK: - gg (scroll to top via two-key sequence)
+
+ func testGGScrollsToTop() {
+ var state = TerminalKeyboardCopyModeInputState()
+ XCTAssertEqual(resolve(5, chars: "g", hasSelection: false, state: &state), .consume)
+ XCTAssertEqual(resolve(5, chars: "g", hasSelection: false, state: &state), .perform(.scrollToTop, count: 1))
+ XCTAssertEqual(state, TerminalKeyboardCopyModeInputState())
+ }
+
+ func testGGWithSelectionAdjustsToHome() {
+ var state = TerminalKeyboardCopyModeInputState()
+ XCTAssertEqual(resolve(5, chars: "g", hasSelection: true, state: &state), .consume)
+ XCTAssertEqual(resolve(5, chars: "g", hasSelection: true, state: &state), .perform(.adjustSelection(.home), count: 1))
+ XCTAssertEqual(state, TerminalKeyboardCopyModeInputState())
+ }
+
+ func testCountedGG() {
+ var state = TerminalKeyboardCopyModeInputState()
+ XCTAssertEqual(resolve(22, chars: "5", hasSelection: false, state: &state), .consume)
+ XCTAssertEqual(resolve(5, chars: "g", hasSelection: false, state: &state), .consume)
+ XCTAssertEqual(resolve(5, chars: "g", hasSelection: false, state: &state), .perform(.scrollToTop, count: 5))
+ }
+
+ func testPendingGCancelledByOtherKey() {
+ var state = TerminalKeyboardCopyModeInputState()
+ XCTAssertEqual(resolve(5, chars: "g", hasSelection: false, state: &state), .consume)
+ XCTAssertEqual(resolve(38, chars: "j", hasSelection: false, state: &state), .perform(.scrollLines(1), count: 1))
+ XCTAssertEqual(state, TerminalKeyboardCopyModeInputState())
+ }
+
+ func testShiftGStillWorksImmediately() {
+ var state = TerminalKeyboardCopyModeInputState()
+ XCTAssertEqual(
+ resolve(5, chars: "g", modifiers: [.shift], hasSelection: false, state: &state),
+ .perform(.scrollToBottom, count: 1)
+ )
+ XCTAssertEqual(state, TerminalKeyboardCopyModeInputState())
+ }
+
+ // MARK: - Ctrl+U/D half-page scroll
+
+ func testCtrlUHalfPage() {
+ var state = TerminalKeyboardCopyModeInputState()
+ XCTAssertEqual(
+ resolve(32, chars: "u", modifiers: [.control], hasSelection: false, state: &state),
+ .perform(.scrollHalfPage(-1), count: 1)
+ )
+ }
+
+ func testCtrlDHalfPage() {
+ var state = TerminalKeyboardCopyModeInputState()
+ XCTAssertEqual(
+ resolve(2, chars: "d", modifiers: [.control], hasSelection: false, state: &state),
+ .perform(.scrollHalfPage(1), count: 1)
+ )
+ }
+
+ func testCtrlBFullPage() {
+ var state = TerminalKeyboardCopyModeInputState()
+ XCTAssertEqual(
+ resolve(11, chars: "b", modifiers: [.control], hasSelection: false, state: &state),
+ .perform(.scrollPage(-1), count: 1)
+ )
+ }
+
+ func testCtrlFFullPage() {
+ var state = TerminalKeyboardCopyModeInputState()
+ XCTAssertEqual(
+ resolve(3, chars: "f", modifiers: [.control], hasSelection: false, state: &state),
+ .perform(.scrollPage(1), count: 1)
+ )
+ }
+}
+
+
+final class TerminalKeyboardCopyModeViewportRowTests: XCTestCase {
+ func testInitialViewportRowUsesImePointBaseline() {
+ XCTAssertEqual(
+ terminalKeyboardCopyModeInitialViewportRow(
+ rows: 24,
+ imePointY: 24,
+ imeCellHeight: 24
+ ),
+ 0
+ )
+ XCTAssertEqual(
+ terminalKeyboardCopyModeInitialViewportRow(
+ rows: 24,
+ imePointY: 240,
+ imeCellHeight: 24
+ ),
+ 9
+ )
+ XCTAssertEqual(
+ terminalKeyboardCopyModeInitialViewportRow(
+ rows: 24,
+ imePointY: 48,
+ imeCellHeight: 24,
+ topPadding: 24
+ ),
+ 0
+ )
+ }
+
+ func testInitialViewportRowClampsBoundsAndFallsBackWhenHeightMissing() {
+ XCTAssertEqual(
+ terminalKeyboardCopyModeInitialViewportRow(
+ rows: 24,
+ imePointY: 0,
+ imeCellHeight: 24
+ ),
+ 0
+ )
+ XCTAssertEqual(
+ terminalKeyboardCopyModeInitialViewportRow(
+ rows: 24,
+ imePointY: 9999,
+ imeCellHeight: 24
+ ),
+ 23
+ )
+ XCTAssertEqual(
+ terminalKeyboardCopyModeInitialViewportRow(
+ rows: 24,
+ imePointY: 123,
+ imeCellHeight: 0
+ ),
+ 23
+ )
+ }
+}
+
+
+final class GhosttyBackgroundThemeTests: XCTestCase {
+ func testColorClampsOpacity() {
+ let base = NSColor(srgbRed: 0.10, green: 0.20, blue: 0.30, alpha: 1.0)
+
+ let lowerClamped = GhosttyBackgroundTheme.color(backgroundColor: base, opacity: -2.0)
+ XCTAssertEqual(lowerClamped.alphaComponent, 0.0, accuracy: 0.0001)
+
+ let upperClamped = GhosttyBackgroundTheme.color(backgroundColor: base, opacity: 5.0)
+ XCTAssertEqual(upperClamped.alphaComponent, 1.0, accuracy: 0.0001)
+ }
+
+ func testColorFromNotificationUsesBackgroundAndOpacity() {
+ let fallbackColor = NSColor.black
+ let fallbackOpacity = 1.0
+ let notification = Notification(
+ name: .ghosttyDefaultBackgroundDidChange,
+ object: nil,
+ userInfo: [
+ GhosttyNotificationKey.backgroundColor: NSColor(srgbRed: 0.18, green: 0.29, blue: 0.44, alpha: 1.0),
+ GhosttyNotificationKey.backgroundOpacity: NSNumber(value: 0.57),
+ ]
+ )
+
+ let actual = GhosttyBackgroundTheme.color(
+ from: notification,
+ fallbackColor: fallbackColor,
+ fallbackOpacity: fallbackOpacity
+ )
+ guard let srgb = actual.usingColorSpace(.sRGB) else {
+ XCTFail("Expected sRGB-convertible color")
+ return
+ }
+
+ XCTAssertEqual(srgb.redComponent, 0.18, accuracy: 0.005)
+ XCTAssertEqual(srgb.greenComponent, 0.29, accuracy: 0.005)
+ XCTAssertEqual(srgb.blueComponent, 0.44, accuracy: 0.005)
+ XCTAssertEqual(srgb.alphaComponent, 0.57, accuracy: 0.005)
+ }
+
+ func testColorFromNotificationFallsBackWhenPayloadMissing() {
+ let fallbackColor = NSColor(srgbRed: 0.12, green: 0.34, blue: 0.56, alpha: 1.0)
+ let fallbackOpacity = 0.42
+ let notification = Notification(name: .ghosttyDefaultBackgroundDidChange)
+
+ let actual = GhosttyBackgroundTheme.color(
+ from: notification,
+ fallbackColor: fallbackColor,
+ fallbackOpacity: fallbackOpacity
+ )
+ guard let srgb = actual.usingColorSpace(.sRGB) else {
+ XCTFail("Expected sRGB-convertible color")
+ return
+ }
+
+ XCTAssertEqual(srgb.redComponent, 0.12, accuracy: 0.005)
+ XCTAssertEqual(srgb.greenComponent, 0.34, accuracy: 0.005)
+ XCTAssertEqual(srgb.blueComponent, 0.56, accuracy: 0.005)
+ XCTAssertEqual(srgb.alphaComponent, 0.42, accuracy: 0.005)
+ }
+}
+
+
+final class GhosttyResponderResolutionTests: XCTestCase {
+ private final class FocusProbeView: NSView {
+ override var acceptsFirstResponder: Bool { true }
+ }
+
+ func testResolvesGhosttyViewFromDescendantResponder() {
+ let ghosttyView = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 200, height: 120))
+ let descendant = FocusProbeView(frame: NSRect(x: 0, y: 0, width: 40, height: 40))
+ ghosttyView.addSubview(descendant)
+
+ XCTAssertTrue(cmuxOwningGhosttyView(for: descendant) === ghosttyView)
+ }
+
+ func testResolvesGhosttyViewFromGhosttyResponder() {
+ let ghosttyView = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 200, height: 120))
+ XCTAssertTrue(cmuxOwningGhosttyView(for: ghosttyView) === ghosttyView)
+ }
+
+ func testReturnsNilForUnrelatedResponder() {
+ let view = FocusProbeView(frame: NSRect(x: 0, y: 0, width: 40, height: 40))
+ XCTAssertNil(cmuxOwningGhosttyView(for: view))
+ }
+}
+
+
+final class TerminalDirectoryOpenTargetAvailabilityTests: XCTestCase {
+ private func environment(
+ existingPaths: Set,
+ homeDirectoryPath: String = "/Users/tester",
+ applicationPathsByName: [String: String] = [:]
+ ) -> TerminalDirectoryOpenTarget.DetectionEnvironment {
+ TerminalDirectoryOpenTarget.DetectionEnvironment(
+ homeDirectoryPath: homeDirectoryPath,
+ fileExistsAtPath: { existingPaths.contains($0) },
+ isExecutableFileAtPath: { existingPaths.contains($0) },
+ applicationPathForName: { applicationPathsByName[$0] }
+ )
+ }
+
+ func testAvailableTargetsDetectSystemApplications() {
+ let env = environment(
+ existingPaths: [
+ "/Applications/Visual Studio Code.app",
+ "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code-tunnel",
+ "/System/Library/CoreServices/Finder.app",
+ "/System/Applications/Utilities/Terminal.app",
+ "/Applications/Zed Preview.app",
+ ]
+ )
+
+ let availableTargets = TerminalDirectoryOpenTarget.availableTargets(in: env)
+ XCTAssertTrue(availableTargets.contains(.vscode))
+ XCTAssertTrue(availableTargets.contains(.finder))
+ XCTAssertTrue(availableTargets.contains(.terminal))
+ XCTAssertTrue(availableTargets.contains(.zed))
+ XCTAssertFalse(availableTargets.contains(.cursor))
+ }
+
+ func testAvailableTargetsFallbackToUserApplications() {
+ let env = environment(
+ existingPaths: [
+ "/Users/tester/Applications/Cursor.app",
+ "/Users/tester/Applications/Warp.app",
+ "/Users/tester/Applications/Android Studio.app",
+ ]
+ )
+
+ let availableTargets = TerminalDirectoryOpenTarget.availableTargets(in: env)
+ XCTAssertTrue(availableTargets.contains(.cursor))
+ XCTAssertTrue(availableTargets.contains(.warp))
+ XCTAssertTrue(availableTargets.contains(.androidStudio))
+ XCTAssertFalse(availableTargets.contains(.vscode))
+ }
+
+ func testVSCodeInlineRequiresCodeTunnelExecutable() {
+ let env = environment(existingPaths: ["/Applications/Visual Studio Code.app"])
+ XCTAssertTrue(TerminalDirectoryOpenTarget.vscode.isAvailable(in: env))
+ XCTAssertFalse(TerminalDirectoryOpenTarget.vscodeInline.isAvailable(in: env))
+ }
+
+ func testITerm2DetectsLegacyBundleName() {
+ let env = environment(existingPaths: ["/Applications/iTerm.app"])
+ XCTAssertTrue(TerminalDirectoryOpenTarget.iterm2.isAvailable(in: env))
+ }
+
+ func testTowerDetected() {
+ let env = environment(existingPaths: ["/Applications/Tower.app"])
+ XCTAssertTrue(TerminalDirectoryOpenTarget.tower.isAvailable(in: env))
+ }
+
+ func testAvailableTargetsFallbackToApplicationLookupForVSCodeAliasOutsideApplications() {
+ let vscodePath = "/Volumes/Tools/Code.app"
+ let env = environment(
+ existingPaths: [
+ vscodePath,
+ "\(vscodePath)/Contents/Resources/app/bin/code-tunnel",
+ ],
+ applicationPathsByName: [
+ "Code": vscodePath,
+ ]
+ )
+
+ let availableTargets = TerminalDirectoryOpenTarget.availableTargets(in: env)
+ XCTAssertTrue(availableTargets.contains(.vscode))
+ XCTAssertTrue(availableTargets.contains(.vscodeInline))
+ }
+
+ func testTowerDetectedViaApplicationLookupOutsideApplications() {
+ let towerPath = "/Volumes/Setapp/Tower.app"
+ let env = environment(
+ existingPaths: [towerPath],
+ applicationPathsByName: [
+ "Tower": towerPath,
+ ]
+ )
+
+ XCTAssertTrue(TerminalDirectoryOpenTarget.tower.isAvailable(in: env))
+ }
+
+ func testCommandPaletteShortcutsExcludeGenericIDEEntry() {
+ let targets = TerminalDirectoryOpenTarget.commandPaletteShortcutTargets
+ XCTAssertFalse(targets.contains(where: { $0.commandPaletteTitle == "Open Current Directory in IDE" }))
+ XCTAssertFalse(targets.contains(where: { $0.commandPaletteCommandId == "palette.terminalOpenDirectory" }))
+ }
+}
+
+
+@MainActor
+final class TerminalNotificationDirectInteractionTests: XCTestCase {
+ private func makeWindow() -> NSWindow {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 480, height: 320),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ window.contentView = NSView(frame: window.contentRect(forFrameRect: window.frame))
+ return window
+ }
+
+ private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent {
+ guard let event = NSEvent.mouseEvent(
+ with: type,
+ location: location,
+ modifierFlags: [],
+ timestamp: ProcessInfo.processInfo.systemUptime,
+ windowNumber: window.windowNumber,
+ context: nil,
+ eventNumber: 0,
+ clickCount: 1,
+ pressure: 1.0
+ ) else {
+ fatalError("Failed to create \(type) mouse event")
+ }
+ return event
+ }
+
+ private func makeKeyEvent(characters: String, keyCode: UInt16, window: NSWindow) -> NSEvent {
+ guard let event = NSEvent.keyEvent(
+ with: .keyDown,
+ location: .zero,
+ modifierFlags: [],
+ timestamp: ProcessInfo.processInfo.systemUptime,
+ windowNumber: window.windowNumber,
+ context: nil,
+ characters: characters,
+ charactersIgnoringModifiers: characters,
+ isARepeat: false,
+ keyCode: keyCode
+ ) else {
+ fatalError("Failed to create key event")
+ }
+ return event
+ }
+
+ private func surfaceView(in hostedView: GhosttySurfaceScrollView) -> NSView? {
+ hostedView.subviews
+ .compactMap { $0 as? NSScrollView }
+ .first?
+ .documentView?
+ .subviews
+ .first
+ }
+
+ func testTerminalMouseDownDismissesUnreadWhenSurfaceIsAlreadyFirstResponder() {
+ let appDelegate = AppDelegate.shared ?? AppDelegate()
+ let manager = TabManager()
+ let store = TerminalNotificationStore.shared
+ let window = makeWindow()
+
+ let originalTabManager = appDelegate.tabManager
+ let originalNotificationStore = appDelegate.notificationStore
+ let originalAppFocusOverride = AppFocusState.overrideIsFocused
+
+ store.replaceNotificationsForTesting([])
+ store.configureNotificationDeliveryHandlerForTesting { _, _ in }
+ appDelegate.tabManager = manager
+ appDelegate.notificationStore = store
+
+ defer {
+ store.replaceNotificationsForTesting([])
+ store.resetNotificationDeliveryHandlerForTesting()
+ appDelegate.tabManager = originalTabManager
+ appDelegate.notificationStore = originalNotificationStore
+ AppFocusState.overrideIsFocused = originalAppFocusOverride
+ window.orderOut(nil)
+ }
+
+ guard let workspace = manager.selectedWorkspace,
+ let terminalPanel = workspace.focusedTerminalPanel else {
+ XCTFail("Expected an initial focused terminal panel")
+ return
+ }
+
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let hostedView = terminalPanel.hostedView
+ hostedView.frame = contentView.bounds
+ hostedView.autoresizingMask = [.width, .height]
+ contentView.addSubview(hostedView)
+ contentView.layoutSubtreeIfNeeded()
+ hostedView.layoutSubtreeIfNeeded()
+
+ guard let surfaceView = surfaceView(in: hostedView) else {
+ XCTFail("Expected terminal surface view")
+ return
+ }
+
+ GhosttySurfaceScrollView.resetFlashCounts()
+ AppFocusState.overrideIsFocused = true
+ XCTAssertTrue(window.makeFirstResponder(surfaceView))
+
+ store.addNotification(
+ tabId: workspace.id,
+ surfaceId: terminalPanel.id,
+ title: "Unread",
+ subtitle: "",
+ body: ""
+ )
+ XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id))
+
+ AppFocusState.overrideIsFocused = true
+ let pointInWindow = surfaceView.convert(NSPoint(x: 20, y: 20), to: nil)
+ let event = makeMouseEvent(type: .leftMouseDown, location: pointInWindow, window: window)
+ surfaceView.mouseDown(with: event)
+ let drained = expectation(description: "flash drained")
+ DispatchQueue.main.async { drained.fulfill() }
+ wait(for: [drained], timeout: 1.0)
+
+ XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id))
+ XCTAssertEqual(GhosttySurfaceScrollView.flashCount(for: terminalPanel.id), 1)
+ }
+
+ func testTerminalKeyDownDismissesUnreadWhenSurfaceIsAlreadyFirstResponder() {
+ let appDelegate = AppDelegate.shared ?? AppDelegate()
+ let manager = TabManager()
+ let store = TerminalNotificationStore.shared
+ let window = makeWindow()
+
+ let originalTabManager = appDelegate.tabManager
+ let originalNotificationStore = appDelegate.notificationStore
+ let originalAppFocusOverride = AppFocusState.overrideIsFocused
+
+ store.replaceNotificationsForTesting([])
+ store.configureNotificationDeliveryHandlerForTesting { _, _ in }
+ appDelegate.tabManager = manager
+ appDelegate.notificationStore = store
+
+ defer {
+ store.replaceNotificationsForTesting([])
+ store.resetNotificationDeliveryHandlerForTesting()
+ appDelegate.tabManager = originalTabManager
+ appDelegate.notificationStore = originalNotificationStore
+ AppFocusState.overrideIsFocused = originalAppFocusOverride
+ window.orderOut(nil)
+ }
+
+ guard let workspace = manager.selectedWorkspace,
+ let terminalPanel = workspace.focusedTerminalPanel else {
+ XCTFail("Expected an initial focused terminal panel")
+ return
+ }
+
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let hostedView = terminalPanel.hostedView
+ hostedView.frame = contentView.bounds
+ hostedView.autoresizingMask = [.width, .height]
+ contentView.addSubview(hostedView)
+ contentView.layoutSubtreeIfNeeded()
+ hostedView.layoutSubtreeIfNeeded()
+
+ guard let surfaceView = surfaceView(in: hostedView) as? GhosttyNSView else {
+ XCTFail("Expected terminal surface view")
+ return
+ }
+
+ GhosttySurfaceScrollView.resetFlashCounts()
+ AppFocusState.overrideIsFocused = true
+ XCTAssertTrue(window.makeFirstResponder(surfaceView))
+
+ store.addNotification(
+ tabId: workspace.id,
+ surfaceId: terminalPanel.id,
+ title: "Unread",
+ subtitle: "",
+ body: ""
+ )
+ XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id))
+
+ let event = makeKeyEvent(characters: "", keyCode: 122, window: window)
+ surfaceView.keyDown(with: event)
+ let drained = expectation(description: "flash drained")
+ DispatchQueue.main.async { drained.fulfill() }
+ wait(for: [drained], timeout: 1.0)
+
+ XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id))
+ XCTAssertEqual(GhosttySurfaceScrollView.flashCount(for: terminalPanel.id), 1)
+ }
+}
+
+
+@MainActor
+final class WindowTerminalHostViewTests: XCTestCase {
+ private final class CapturingView: NSView {
+ override func hitTest(_ point: NSPoint) -> NSView? {
+ bounds.contains(point) ? self : nil
+ }
+ }
+
+ private final class BonsplitMockSplitDelegate: NSObject, NSSplitViewDelegate {}
+
+ func testHostViewPassesThroughWhenNoTerminalSubviewIsHit() {
+ let host = WindowTerminalHostView(frame: NSRect(x: 0, y: 0, width: 200, height: 120))
+
+ XCTAssertNil(host.hitTest(NSPoint(x: 10, y: 10)))
+ }
+
+ func testHostViewReturnsSubviewWhenSubviewIsHit() {
+ let host = WindowTerminalHostView(frame: NSRect(x: 0, y: 0, width: 200, height: 120))
+ let child = CapturingView(frame: NSRect(x: 20, y: 15, width: 40, height: 30))
+ host.addSubview(child)
+
+ XCTAssertTrue(host.hitTest(NSPoint(x: 25, y: 20)) === child)
+ XCTAssertNil(host.hitTest(NSPoint(x: 150, y: 100)))
+ }
+
+ func testHostViewPassesThroughDividerWhenAdjacentPaneIsCollapsed() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 300, height: 180),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let splitView = NSSplitView(frame: contentView.bounds)
+ splitView.autoresizingMask = [.width, .height]
+ splitView.isVertical = true
+ splitView.dividerStyle = .thin
+ let splitDelegate = BonsplitMockSplitDelegate()
+ splitView.delegate = splitDelegate
+ let first = NSView(frame: NSRect(x: 0, y: 0, width: 120, height: contentView.bounds.height))
+ let second = NSView(frame: NSRect(x: 121, y: 0, width: 179, height: contentView.bounds.height))
+ splitView.addSubview(first)
+ splitView.addSubview(second)
+ contentView.addSubview(splitView)
+ splitView.setPosition(1, ofDividerAt: 0)
+ splitView.adjustSubviews()
+ contentView.layoutSubtreeIfNeeded()
+
+ let host = WindowTerminalHostView(frame: contentView.bounds)
+ host.autoresizingMask = [.width, .height]
+ let child = CapturingView(frame: host.bounds)
+ child.autoresizingMask = [.width, .height]
+ host.addSubview(child)
+ contentView.addSubview(host)
+
+ let dividerPointInSplit = NSPoint(
+ x: splitView.arrangedSubviews[0].frame.maxX + (splitView.dividerThickness * 0.5),
+ y: splitView.bounds.midY
+ )
+ let dividerPointInWindow = splitView.convert(dividerPointInSplit, to: nil)
+ let dividerPointInHost = host.convert(dividerPointInWindow, from: nil)
+ XCTAssertLessThanOrEqual(splitView.arrangedSubviews[0].frame.width, 1.5)
+ XCTAssertNil(
+ host.hitTest(dividerPointInHost),
+ "Host view must pass through divider hits even when one pane is nearly collapsed"
+ )
+
+ let contentPointInSplit = NSPoint(x: dividerPointInSplit.x + 40, y: splitView.bounds.midY)
+ let contentPointInWindow = splitView.convert(contentPointInSplit, to: nil)
+ let contentPointInHost = host.convert(contentPointInWindow, from: nil)
+ XCTAssertTrue(host.hitTest(contentPointInHost) === child)
+ }
+}
+
+
+@MainActor
+final class GhosttySurfaceOverlayTests: XCTestCase {
+ private final class ScrollProbeSurfaceView: GhosttyNSView {
+ private(set) var scrollWheelCallCount = 0
+
+ override func scrollWheel(with event: NSEvent) {
+ scrollWheelCallCount += 1
+ }
+ }
+
+ private func findEditableTextField(in view: NSView) -> NSTextField? {
+ if let field = view as? NSTextField, field.isEditable {
+ return field
+ }
+ for subview in view.subviews {
+ if let field = findEditableTextField(in: subview) {
+ return field
+ }
+ }
+ return nil
+ }
+
+ private func firstResponderOwnsTextField(_ firstResponder: NSResponder?, textField: NSTextField) -> Bool {
+ if firstResponder === textField {
+ return true
+ }
+ if let editor = firstResponder as? NSTextView,
+ editor.isFieldEditor,
+ editor.delegate as? NSTextField === textField {
+ return true
+ }
+ return false
+ }
+
+ func testTrackpadScrollRoutesToTerminalSurfaceAndPreservesKeyboardFocusPath() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let surfaceView = ScrollProbeSurfaceView(frame: NSRect(x: 0, y: 0, width: 160, height: 120))
+ let hostedView = GhosttySurfaceScrollView(surfaceView: surfaceView)
+ hostedView.frame = contentView.bounds
+ hostedView.autoresizingMask = [.width, .height]
+ contentView.addSubview(hostedView)
+
+ window.makeKeyAndOrderFront(nil)
+ window.displayIfNeeded()
+ contentView.layoutSubtreeIfNeeded()
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+
+ guard let scrollView = hostedView.subviews.first(where: { $0 is NSScrollView }) as? NSScrollView else {
+ XCTFail("Expected hosted terminal scroll view")
+ return
+ }
+ XCTAssertFalse(
+ scrollView.acceptsFirstResponder,
+ "Host scroll view should not become first responder and steal terminal shortcuts"
+ )
+
+ _ = window.makeFirstResponder(nil)
+
+ guard let cgEvent = CGEvent(
+ scrollWheelEvent2Source: nil,
+ units: .pixel,
+ wheelCount: 2,
+ wheel1: 0,
+ wheel2: -12,
+ wheel3: 0
+ ), let scrollEvent = NSEvent(cgEvent: cgEvent) else {
+ XCTFail("Expected scroll wheel event")
+ return
+ }
+
+ scrollView.scrollWheel(with: scrollEvent)
+
+ XCTAssertEqual(
+ surfaceView.scrollWheelCallCount,
+ 1,
+ "Trackpad wheel events should be forwarded directly to Ghostty surface scrolling"
+ )
+ XCTAssertTrue(
+ window.firstResponder === surfaceView,
+ "Scroll wheel handling should keep keyboard focus on terminal surface"
+ )
+ }
+
+ func testInactiveOverlayVisibilityTracksRequestedState() {
+ let hostedView = GhosttySurfaceScrollView(
+ surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 80, height: 50))
+ )
+
+ hostedView.setInactiveOverlay(color: .black, opacity: 0.35, visible: true)
+ var state = hostedView.debugInactiveOverlayState()
+ XCTAssertFalse(state.isHidden)
+ XCTAssertEqual(state.alpha, 0.35, accuracy: 0.01)
+
+ hostedView.setInactiveOverlay(color: .black, opacity: 0.35, visible: false)
+ state = hostedView.debugInactiveOverlayState()
+ XCTAssertTrue(state.isHidden)
+ }
+
+ func testWindowResignKeyClearsFocusedTerminalFirstResponder() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let hostedView = GhosttySurfaceScrollView(
+ surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 160, height: 120))
+ )
+ hostedView.frame = contentView.bounds
+ hostedView.autoresizingMask = [.width, .height]
+ contentView.addSubview(hostedView)
+
+ window.makeKeyAndOrderFront(nil)
+ window.displayIfNeeded()
+ contentView.layoutSubtreeIfNeeded()
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+
+ hostedView.setVisibleInUI(true)
+ hostedView.setActive(true)
+ hostedView.moveFocus()
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+ XCTAssertTrue(
+ hostedView.isSurfaceViewFirstResponder(),
+ "Expected terminal surface to be first responder before window blur"
+ )
+
+ NotificationCenter.default.post(name: NSWindow.didResignKeyNotification, object: window)
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+
+ XCTAssertFalse(
+ hostedView.isSurfaceViewFirstResponder(),
+ "Window blur should force terminal surface to resign first responder"
+ )
+ }
+
+ func testSearchOverlayMountsAndUnmountsWithSearchState() {
+ let surface = TerminalSurface(
+ tabId: UUID(),
+ context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
+ configTemplate: nil,
+ workingDirectory: nil
+ )
+ let hostedView = surface.hostedView
+ XCTAssertFalse(hostedView.debugHasSearchOverlay())
+
+ let searchState = TerminalSurface.SearchState(needle: "example")
+ hostedView.setSearchOverlay(searchState: searchState)
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+ XCTAssertTrue(hostedView.debugHasSearchOverlay())
+
+ hostedView.setSearchOverlay(searchState: nil)
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+ XCTAssertFalse(hostedView.debugHasSearchOverlay())
+ }
+
+ func testRapidSearchOverlayToggleDoesNotLeaveStaleOverlayMounted() {
+ let surface = TerminalSurface(
+ tabId: UUID(),
+ context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
+ configTemplate: nil,
+ workingDirectory: nil
+ )
+ let hostedView = surface.hostedView
+
+ hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "example"))
+ hostedView.setSearchOverlay(searchState: nil)
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+
+ XCTAssertFalse(
+ hostedView.debugHasSearchOverlay(),
+ "A stale deferred mount must not resurrect the find overlay after it closes"
+ )
+ }
+
+ func testSearchOverlayFocusesSearchFieldAfterDeferredAttach() {
+ let surface = TerminalSurface(
+ tabId: UUID(),
+ context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
+ configTemplate: nil,
+ workingDirectory: nil
+ )
+ let hostedView = surface.hostedView
+
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+ hostedView.frame = contentView.bounds
+ hostedView.autoresizingMask = [.width, .height]
+ contentView.addSubview(hostedView)
+
+ window.makeKeyAndOrderFront(nil)
+ window.displayIfNeeded()
+ contentView.layoutSubtreeIfNeeded()
+ hostedView.setVisibleInUI(true)
+ hostedView.setActive(true)
+
+ let searchState = TerminalSurface.SearchState(needle: "")
+ surface.searchState = searchState
+ hostedView.setSearchOverlay(searchState: searchState)
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+
+ guard let searchField = findEditableTextField(in: hostedView) else {
+ XCTFail("Expected mounted find text field")
+ return
+ }
+
+ XCTAssertTrue(
+ firstResponderOwnsTextField(window.firstResponder, textField: searchField),
+ "Deferred search overlay attach should still move focus into the find field"
+ )
+ }
+
+ func testStartOrFocusTerminalSearchReusesExistingSearchState() {
+ let surface = TerminalSurface(
+ tabId: UUID(),
+ context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
+ configTemplate: nil,
+ workingDirectory: nil
+ )
+ let existingSearchState = TerminalSurface.SearchState(needle: "existing")
+ surface.searchState = existingSearchState
+
+ var focusNotificationCount = 0
+ XCTAssertTrue(
+ startOrFocusTerminalSearch(surface) { _ in
+ focusNotificationCount += 1
+ }
+ )
+
+ XCTAssertTrue(surface.searchState === existingSearchState)
+ XCTAssertEqual(
+ focusNotificationCount,
+ 1,
+ "Re-triggering terminal Find should refocus the existing overlay without recreating state"
+ )
+ }
+
+ func testEscapeDismissingFindOverlayDoesNotLeakEscapeKeyUpToTerminal() {
+ _ = NSApplication.shared
+
+ let surface = TerminalSurface(
+ tabId: UUID(),
+ context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
+ configTemplate: nil,
+ workingDirectory: nil
+ )
+ let hostedView = surface.hostedView
+
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer {
+ GhosttyNSView.debugGhosttySurfaceKeyEventObserver = nil
+ window.orderOut(nil)
+ }
+
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+ hostedView.frame = contentView.bounds
+ hostedView.autoresizingMask = [.width, .height]
+ contentView.addSubview(hostedView)
+
+ window.makeKeyAndOrderFront(nil)
+ window.displayIfNeeded()
+ contentView.layoutSubtreeIfNeeded()
+ hostedView.setVisibleInUI(true)
+ hostedView.setActive(true)
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+
+ let searchState = TerminalSurface.SearchState(needle: "")
+ surface.searchState = searchState
+ hostedView.setSearchOverlay(searchState: searchState)
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+
+ guard let searchField = findEditableTextField(in: hostedView) else {
+ XCTFail("Expected mounted find text field")
+ return
+ }
+ window.makeFirstResponder(searchField)
+
+ var escapeKeyUpCount = 0
+ GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in
+ guard keyEvent.action == GHOSTTY_ACTION_RELEASE, keyEvent.keycode == 53 else { return }
+ escapeKeyUpCount += 1
+ }
+
+ let timestamp = ProcessInfo.processInfo.systemUptime
+ guard let escapeKeyDown = NSEvent.keyEvent(
+ with: .keyDown,
+ location: .zero,
+ modifierFlags: [],
+ timestamp: timestamp,
+ windowNumber: window.windowNumber,
+ context: nil,
+ characters: "\u{1b}",
+ charactersIgnoringModifiers: "\u{1b}",
+ isARepeat: false,
+ keyCode: 53
+ ), let escapeKeyUp = NSEvent.keyEvent(
+ with: .keyUp,
+ location: .zero,
+ modifierFlags: [],
+ timestamp: timestamp + 0.001,
+ windowNumber: window.windowNumber,
+ context: nil,
+ characters: "\u{1b}",
+ charactersIgnoringModifiers: "\u{1b}",
+ isARepeat: false,
+ keyCode: 53
+ ) else {
+ XCTFail("Failed to construct Escape key events")
+ return
+ }
+
+ NSApp.sendEvent(escapeKeyDown)
+ NSApp.sendEvent(escapeKeyUp)
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+
+ XCTAssertNil(surface.searchState, "Escape should dismiss find overlay when search text is empty")
+ XCTAssertEqual(
+ escapeKeyUpCount,
+ 0,
+ "Escape used to dismiss find overlay must not pass through to the terminal key-up path"
+ )
+ }
+
+ @MainActor
+ func testKeyboardCopyModeIndicatorMountsAndUnmounts() {
+ let surface = TerminalSurface(
+ tabId: UUID(),
+ context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
+ configTemplate: nil,
+ workingDirectory: nil
+ )
+ let hostedView = surface.hostedView
+ XCTAssertFalse(hostedView.debugHasKeyboardCopyModeIndicator())
+
+ hostedView.syncKeyStateIndicator(text: "vim")
+ XCTAssertTrue(hostedView.debugHasKeyboardCopyModeIndicator())
+
+ hostedView.syncKeyStateIndicator(text: nil)
+ XCTAssertFalse(hostedView.debugHasKeyboardCopyModeIndicator())
+ }
+
+ @MainActor
+ func testDropHoverOverlayAttachesToParentContainerInsteadOfHostedTerminalView() {
+ let container = NSView(frame: NSRect(x: 0, y: 0, width: 240, height: 120))
+ let surfaceView = GhosttyNSView(frame: .zero)
+ let hostedView = GhosttySurfaceScrollView(surfaceView: surfaceView)
+ hostedView.frame = container.bounds
+ container.addSubview(hostedView)
+
+ hostedView.setDropZoneOverlay(zone: .right)
+ container.layoutSubtreeIfNeeded()
+
+ let state = hostedView.debugDropZoneOverlayState()
+ XCTAssertFalse(state.isHidden)
+ XCTAssertFalse(
+ state.isAttachedToHostedView,
+ "Drop-hover overlay should be mounted outside the hosted terminal view"
+ )
+ XCTAssertTrue(
+ state.isAttachedToParentContainer,
+ "Drop-hover overlay should be mounted in the parent container so it cannot perturb terminal layout"
+ )
+ XCTAssertEqual(state.frame.origin.x, 120, accuracy: 0.5)
+ XCTAssertEqual(state.frame.origin.y, 4, accuracy: 0.5)
+ XCTAssertEqual(state.frame.size.width, 116, accuracy: 0.5)
+ XCTAssertEqual(state.frame.size.height, 112, accuracy: 0.5)
+
+ hostedView.setDropZoneOverlay(zone: nil)
+ RunLoop.current.run(until: Date().addingTimeInterval(0.25))
+ XCTAssertTrue(hostedView.debugDropZoneOverlayState().isHidden)
+ }
+
+ func testForceRefreshNoopsAfterSurfaceReleaseDuringGeometryReconcile() throws {
+#if DEBUG
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 420, height: 280),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let surface = TerminalSurface(
+ tabId: UUID(),
+ context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
+ configTemplate: nil,
+ workingDirectory: nil
+ )
+ let hostedView = surface.hostedView
+ hostedView.frame = contentView.bounds
+ hostedView.autoresizingMask = [.width, .height]
+ contentView.addSubview(hostedView)
+
+ window.makeKeyAndOrderFront(nil)
+ window.displayIfNeeded()
+ contentView.layoutSubtreeIfNeeded()
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+
+ hostedView.reconcileGeometryNow()
+ surface.releaseSurfaceForTesting()
+ XCTAssertNil(surface.surface, "Surface should be nil after test release helper")
+
+ hostedView.reconcileGeometryNow()
+ surface.forceRefresh()
+ XCTAssertNil(surface.surface, "Force refresh should no-op when runtime surface is nil")
+#else
+ throw XCTSkip("Debug-only regression test")
+#endif
+ }
+
+ func testSearchOverlayMountDoesNotRetainTerminalSurface() {
+ weak var weakSurface: TerminalSurface?
+
+ let hostedView: GhosttySurfaceScrollView = {
+ let surface = TerminalSurface(
+ tabId: UUID(),
+ context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
+ configTemplate: nil,
+ workingDirectory: nil
+ )
+ weakSurface = surface
+ let hostedView = surface.hostedView
+ hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "retain-check"))
+ return hostedView
+ }()
+
+ RunLoop.main.run(until: Date().addingTimeInterval(0.01))
+ XCTAssertTrue(hostedView.debugHasSearchOverlay())
+ XCTAssertNil(weakSurface, "Mounted search overlay must not retain TerminalSurface")
+ }
+
+ func testSearchOverlaySurvivesPortalRebindDuringSplitLikeChurn() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 480, height: 320),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ let portal = WindowTerminalPortal(window: window)
+
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let anchorA = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 140))
+ let anchorB = NSView(frame: NSRect(x: 220, y: 20, width: 180, height: 140))
+ contentView.addSubview(anchorA)
+ contentView.addSubview(anchorB)
+
+ let surface = TerminalSurface(
+ tabId: UUID(),
+ context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
+ configTemplate: nil,
+ workingDirectory: nil
+ )
+ let hostedView = surface.hostedView
+ hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "split"))
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+ XCTAssertTrue(hostedView.debugHasSearchOverlay())
+
+ portal.bind(hostedView: hostedView, to: anchorA, visibleInUI: true)
+ XCTAssertTrue(hostedView.debugHasSearchOverlay())
+
+ portal.bind(hostedView: hostedView, to: anchorB, visibleInUI: true)
+ XCTAssertTrue(
+ hostedView.debugHasSearchOverlay(),
+ "Split-like anchor churn should not unmount terminal search overlay"
+ )
+ }
+
+ func testSearchOverlaySurvivesPortalVisibilityToggleDuringWorkspaceSwitchLikeChurn() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 480, height: 320),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ let portal = WindowTerminalPortal(window: window)
+
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let anchor = NSView(frame: NSRect(x: 40, y: 40, width: 220, height: 160))
+ contentView.addSubview(anchor)
+
+ let surface = TerminalSurface(
+ tabId: UUID(),
+ context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
+ configTemplate: nil,
+ workingDirectory: nil
+ )
+ let hostedView = surface.hostedView
+ hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "workspace"))
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+ XCTAssertTrue(hostedView.debugHasSearchOverlay())
+
+ portal.bind(hostedView: hostedView, to: anchor, visibleInUI: true)
+ XCTAssertTrue(hostedView.debugHasSearchOverlay())
+
+ portal.bind(hostedView: hostedView, to: anchor, visibleInUI: false)
+ XCTAssertTrue(hostedView.debugHasSearchOverlay())
+
+ portal.bind(hostedView: hostedView, to: anchor, visibleInUI: true)
+ XCTAssertTrue(
+ hostedView.debugHasSearchOverlay(),
+ "Workspace-switch-like visibility toggles should not unmount terminal search overlay"
+ )
+ }
+}
+
+
+@MainActor
+final class TerminalWindowPortalLifecycleTests: XCTestCase {
+ private final class ContentViewCountingWindow: NSWindow {
+ var contentViewReadCount = 0
+
+ override var contentView: NSView? {
+ get {
+ contentViewReadCount += 1
+ return super.contentView
+ }
+ set {
+ super.contentView = newValue
+ }
+ }
+ }
+
+ private func realizeWindowLayout(_ window: NSWindow) {
+ window.makeKeyAndOrderFront(nil)
+ window.displayIfNeeded()
+ window.contentView?.layoutSubtreeIfNeeded()
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+ window.contentView?.layoutSubtreeIfNeeded()
+ }
+
+ func testPortalHostInstallsAboveContentViewForVisibility() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ let portal = WindowTerminalPortal(window: window)
+ _ = portal.viewAtWindowPoint(NSPoint(x: 1, y: 1))
+
+ guard let contentView = window.contentView,
+ let container = contentView.superview else {
+ XCTFail("Expected content container")
+ return
+ }
+
+ guard let hostIndex = container.subviews.firstIndex(where: { $0 is WindowTerminalHostView }),
+ let contentIndex = container.subviews.firstIndex(where: { $0 === contentView }) else {
+ XCTFail("Expected host/content views in same container")
+ return
+ }
+
+ XCTAssertGreaterThan(
+ hostIndex,
+ contentIndex,
+ "Portal host must remain above content view so portal-hosted terminals stay visible"
+ )
+ }
+
+ func testTerminalPortalHostStaysBelowBrowserPortalHostWhenBothAreInstalled() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ realizeWindowLayout(window)
+
+ let browserPortal = WindowBrowserPortal(window: window)
+ let terminalPortal = WindowTerminalPortal(window: window)
+ _ = browserPortal.webViewAtWindowPoint(NSPoint(x: 1, y: 1))
+ _ = terminalPortal.viewAtWindowPoint(NSPoint(x: 1, y: 1))
+
+ guard let contentView = window.contentView,
+ let container = contentView.superview else {
+ XCTFail("Expected content container")
+ return
+ }
+
+ func assertHostOrder(_ message: String) {
+ guard let terminalHostIndex = container.subviews.firstIndex(where: { $0 is WindowTerminalHostView }),
+ let browserHostIndex = container.subviews.firstIndex(where: { $0 is WindowBrowserHostView }) else {
+ XCTFail("Expected both portal hosts in same container")
+ return
+ }
+
+ XCTAssertLessThan(
+ terminalHostIndex,
+ browserHostIndex,
+ message
+ )
+ }
+
+ assertHostOrder("Terminal portal host should start below browser portal host")
+
+ let anchor = NSView(frame: NSRect(x: 24, y: 24, width: 220, height: 150))
+ contentView.addSubview(anchor)
+ let hosted = GhosttySurfaceScrollView(
+ surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80))
+ )
+ terminalPortal.bind(hostedView: hosted, to: anchor, visibleInUI: true)
+ terminalPortal.synchronizeHostedViewForAnchor(anchor)
+
+ assertHostOrder("Terminal portal bind/sync should not rise above the browser portal host")
+ }
+
+ func testRegistryPrunesPortalWhenWindowCloses() {
+ let baseline = TerminalWindowPortalRegistry.debugPortalCount()
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+
+ _ = TerminalWindowPortalRegistry.viewAtWindowPoint(NSPoint(x: 1, y: 1), in: window)
+ XCTAssertEqual(TerminalWindowPortalRegistry.debugPortalCount(), baseline + 1)
+
+ NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window)
+ XCTAssertEqual(TerminalWindowPortalRegistry.debugPortalCount(), baseline)
+ }
+
+ func testPruneDeadEntriesDetachesAnchorlessHostedView() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ let portal = WindowTerminalPortal(window: window)
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let hosted1 = GhosttySurfaceScrollView(
+ surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 40, height: 30))
+ )
+
+ var anchor1: NSView? = NSView(frame: NSRect(x: 20, y: 20, width: 120, height: 80))
+ contentView.addSubview(anchor1!)
+ portal.bind(hostedView: hosted1, to: anchor1!, visibleInUI: true)
+
+ anchor1?.removeFromSuperview()
+ anchor1 = nil
+
+ let hosted2 = GhosttySurfaceScrollView(
+ surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 40, height: 30))
+ )
+ let anchor2 = NSView(frame: NSRect(x: 180, y: 20, width: 120, height: 80))
+ contentView.addSubview(anchor2)
+ portal.bind(hostedView: hosted2, to: anchor2, visibleInUI: true)
+
+ XCTAssertEqual(portal.debugEntryCount(), 1, "Only the live anchored hosted view should remain tracked")
+ XCTAssertEqual(portal.debugHostedSubviewCount(), 1, "Stale anchorless hosted views should be detached from hostView")
+ }
+
+ func testSynchronizeReusesInstalledTargetWithoutRepeatedContentViewLookup() {
+ let window = ContentViewCountingWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ let portal = WindowTerminalPortal(window: window)
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let anchor = NSView(frame: NSRect(x: 40, y: 50, width: 200, height: 120))
+ contentView.addSubview(anchor)
+ let hosted = GhosttySurfaceScrollView(
+ surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 100, height: 80))
+ )
+ portal.bind(hostedView: hosted, to: anchor, visibleInUI: true)
+
+ let baselineReads = window.contentViewReadCount
+ for _ in 0..<25 {
+ portal.synchronizeHostedViewForAnchor(anchor)
+ }
+
+ XCTAssertEqual(
+ window.contentViewReadCount,
+ baselineReads,
+ "Repeated synchronize calls should reuse installed target instead of repeatedly reading window.contentView"
+ )
+ }
+
+ func testTerminalViewAtWindowPointResolvesPortalHostedSurface() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ let portal = WindowTerminalPortal(window: window)
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let anchor = NSView(frame: NSRect(x: 40, y: 50, width: 200, height: 120))
+ contentView.addSubview(anchor)
+
+ let hosted = GhosttySurfaceScrollView(
+ surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 100, height: 80))
+ )
+ portal.bind(hostedView: hosted, to: anchor, visibleInUI: true)
+
+ let center = NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY)
+ let windowPoint = anchor.convert(center, to: nil)
+ XCTAssertNotNil(
+ portal.terminalViewAtWindowPoint(windowPoint),
+ "Portal hit-testing should resolve the terminal view for Finder file drops"
+ )
+ }
+
+ func testVisibilityTransitionBringsHostedViewToFront() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ let portal = WindowTerminalPortal(window: window)
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let anchor1 = NSView(frame: NSRect(x: 20, y: 20, width: 220, height: 180))
+ let anchor2 = NSView(frame: NSRect(x: 80, y: 60, width: 220, height: 180))
+ contentView.addSubview(anchor1)
+ contentView.addSubview(anchor2)
+
+ let terminal1 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80))
+ let hosted1 = GhosttySurfaceScrollView(surfaceView: terminal1)
+ let terminal2 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80))
+ let hosted2 = GhosttySurfaceScrollView(surfaceView: terminal2)
+
+ portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true)
+ portal.bind(hostedView: hosted2, to: anchor2, visibleInUI: true)
+
+ let overlapInContent = NSPoint(x: 120, y: 100)
+ let overlapInWindow = contentView.convert(overlapInContent, to: nil)
+ XCTAssertTrue(
+ portal.terminalViewAtWindowPoint(overlapInWindow) === terminal2,
+ "Latest bind should be top-most before visibility transition"
+ )
+
+ portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: false)
+ portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true)
+ XCTAssertTrue(
+ portal.terminalViewAtWindowPoint(overlapInWindow) === terminal1,
+ "Becoming visible should refresh z-order for already-hosted view"
+ )
+ }
+
+ func testPriorityIncreaseBringsHostedViewToFrontWithoutVisibilityToggle() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ let portal = WindowTerminalPortal(window: window)
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let anchor1 = NSView(frame: NSRect(x: 20, y: 20, width: 220, height: 180))
+ let anchor2 = NSView(frame: NSRect(x: 80, y: 60, width: 220, height: 180))
+ contentView.addSubview(anchor1)
+ contentView.addSubview(anchor2)
+
+ let terminal1 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80))
+ let hosted1 = GhosttySurfaceScrollView(surfaceView: terminal1)
+ let terminal2 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80))
+ let hosted2 = GhosttySurfaceScrollView(surfaceView: terminal2)
+
+ portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true, zPriority: 1)
+ portal.bind(hostedView: hosted2, to: anchor2, visibleInUI: true, zPriority: 2)
+
+ let overlapInContent = NSPoint(x: 120, y: 100)
+ let overlapInWindow = contentView.convert(overlapInContent, to: nil)
+ XCTAssertTrue(
+ portal.terminalViewAtWindowPoint(overlapInWindow) === terminal2,
+ "Higher-priority terminal should initially be top-most"
+ )
+
+ portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true, zPriority: 2)
+ XCTAssertTrue(
+ portal.terminalViewAtWindowPoint(overlapInWindow) === terminal1,
+ "Promoting z-priority should bring an already-visible terminal to front"
+ )
+ }
+
+ func testHiddenPortalDefersRevealUntilFrameHasUsableSize() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 700, height: 420),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+
+ let portal = WindowTerminalPortal(window: window)
+ realizeWindowLayout(window)
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let anchor = NSView(frame: NSRect(x: 40, y: 40, width: 280, height: 220))
+ contentView.addSubview(anchor)
+
+ let hosted = GhosttySurfaceScrollView(
+ surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80))
+ )
+ portal.bind(hostedView: hosted, to: anchor, visibleInUI: true)
+ XCTAssertFalse(hosted.isHidden, "Healthy geometry should be visible")
+
+ // Collapse to a tiny frame first.
+ anchor.frame = NSRect(x: 160.5, y: 1037.0, width: 79.0, height: 0.0)
+ portal.synchronizeHostedViewForAnchor(anchor)
+ XCTAssertTrue(hosted.isHidden, "Tiny geometry should hide the portal-hosted terminal")
+
+ // Then restore to a non-zero but still too-small frame. It should remain hidden.
+ anchor.frame = NSRect(x: 160.9, y: 1026.5, width: 93.6, height: 10.3)
+ portal.synchronizeHostedViewForAnchor(anchor)
+ XCTAssertTrue(
+ hosted.isHidden,
+ "Portal should defer reveal until geometry reaches a usable size"
+ )
+
+ // Once the frame is large enough again, reveal should resume.
+ anchor.frame = NSRect(x: 40, y: 40, width: 180, height: 40)
+ portal.synchronizeHostedViewForAnchor(anchor)
+ XCTAssertFalse(hosted.isHidden, "Portal should unhide after geometry is usable")
+ }
+
+ func testScheduledExternalGeometrySyncRefreshesAncestorLayoutShift() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 700, height: 420),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer {
+ NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window)
+ window.orderOut(nil)
+ }
+
+ realizeWindowLayout(window)
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let shiftedContainer = NSView(frame: NSRect(x: 120, y: 60, width: 220, height: 160))
+ contentView.addSubview(shiftedContainer)
+ let anchor = NSView(frame: NSRect(x: 24, y: 28, width: 72, height: 56))
+ shiftedContainer.addSubview(anchor)
+
+ let surface = TerminalSurface(
+ tabId: UUID(),
+ context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
+ configTemplate: nil,
+ workingDirectory: nil
+ )
+ let hosted = surface.hostedView
+ TerminalWindowPortalRegistry.bind(
+ hostedView: hosted,
+ to: anchor,
+ visibleInUI: true,
+ expectedSurfaceId: surface.id,
+ expectedGeneration: surface.portalBindingGeneration()
+ )
+ TerminalWindowPortalRegistry.synchronizeForAnchor(anchor)
+
+ let anchorCenter = NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY)
+ let originalWindowPoint = anchor.convert(anchorCenter, to: nil)
+ XCTAssertNotNil(
+ TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window),
+ "Initial hit-testing should resolve the portal-hosted terminal at its original window position"
+ )
+
+ shiftedContainer.frame.origin.x += 96
+ contentView.layoutSubtreeIfNeeded()
+ window.displayIfNeeded()
+
+ let shiftedWindowPoint = anchor.convert(anchorCenter, to: nil)
+ XCTAssertNotEqual(originalWindowPoint.x, shiftedWindowPoint.x, accuracy: 0.5)
+ XCTAssertNil(
+ TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window),
+ "Ancestor-only layout shifts should leave the portal stale until an external geometry sync runs"
+ )
+ XCTAssertNotNil(
+ TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window),
+ "Before the external geometry sync, hit-testing should still point at the stale portal location"
+ )
+
+ TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows()
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+
+ XCTAssertNil(
+ TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window),
+ "The stale portal position should be cleared after the scheduled external geometry sync"
+ )
+ XCTAssertNotNil(
+ TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window),
+ "The scheduled external geometry sync should move the portal-hosted terminal to the anchor's new window position"
+ )
+ }
+
+ func testScheduledExternalGeometrySyncWaitsForQueuedLayoutShift() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 700, height: 420),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer {
+ NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window)
+ window.orderOut(nil)
+ }
+
+ let surface = TerminalSurface(
+ tabId: UUID(),
+ context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
+ configTemplate: nil,
+ workingDirectory: nil
+ )
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let shiftedContainer = NSView(frame: NSRect(x: 40, y: 60, width: 260, height: 180))
+ contentView.addSubview(shiftedContainer)
+ let anchor = NSView(frame: NSRect(x: 0, y: 0, width: 260, height: 180))
+ shiftedContainer.addSubview(anchor)
+ let hosted = surface.hostedView
+ TerminalWindowPortalRegistry.bind(
+ hostedView: hosted,
+ to: anchor,
+ visibleInUI: true,
+ expectedSurfaceId: surface.id,
+ expectedGeneration: surface.portalBindingGeneration()
+ )
+ TerminalWindowPortalRegistry.synchronizeForAnchor(anchor)
+
+ let anchorCenter = NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY)
+ let originalWindowPoint = anchor.convert(anchorCenter, to: nil)
+ let originalAnchorFrameInWindow = anchor.convert(anchor.bounds, to: nil)
+ XCTAssertNotNil(
+ TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window),
+ "Initial hit-testing should resolve the portal-hosted terminal at its original window position"
+ )
+
+ TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows()
+ DispatchQueue.main.async {
+ shiftedContainer.frame.origin.x += 72
+ contentView.layoutSubtreeIfNeeded()
+ window.displayIfNeeded()
+ }
+
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+
+ let shiftedAnchorFrameInWindow = anchor.convert(anchor.bounds, to: nil)
+ XCTAssertGreaterThan(
+ shiftedAnchorFrameInWindow.minX,
+ originalAnchorFrameInWindow.minX + 1,
+ "The queued layout shift should move the anchor to the right"
+ )
+ XCTAssertGreaterThan(
+ shiftedAnchorFrameInWindow.maxX,
+ originalAnchorFrameInWindow.maxX + 1,
+ "The shifted anchor should expose a new trailing region outside the stale portal frame"
+ )
+ let retiredStaleWindowPoint = NSPoint(
+ x: (originalAnchorFrameInWindow.minX + shiftedAnchorFrameInWindow.minX) / 2,
+ y: shiftedAnchorFrameInWindow.midY
+ )
+ let shiftedWindowPoint = NSPoint(
+ x: (originalAnchorFrameInWindow.maxX + shiftedAnchorFrameInWindow.maxX) / 2,
+ y: shiftedAnchorFrameInWindow.midY
+ )
+ XCTAssertNil(
+ TerminalWindowPortalRegistry.terminalViewAtWindowPoint(retiredStaleWindowPoint, in: window),
+ "The queued external sync should wait until the later layout shift settles, clearing the stale portal location"
+ )
+ XCTAssertNotNil(
+ TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window),
+ "The delayed external sync should move the portal-hosted terminal to the queued layout shift position"
+ )
+ }
+}
+
+
+final class TerminalOpenURLTargetResolutionTests: XCTestCase {
+ func testResolvesHTTPSAsEmbeddedBrowser() throws {
+ let target = try XCTUnwrap(resolveTerminalOpenURLTarget("https://example.com/path?q=1"))
+ switch target {
+ case let .embeddedBrowser(url):
+ XCTAssertEqual(url.scheme, "https")
+ XCTAssertEqual(url.host, "example.com")
+ XCTAssertEqual(url.path, "/path")
+ default:
+ XCTFail("Expected web URL to route to embedded browser")
+ }
+ }
+
+ func testResolvesBareDomainAsEmbeddedBrowser() throws {
+ let target = try XCTUnwrap(resolveTerminalOpenURLTarget("example.com/docs"))
+ switch target {
+ case let .embeddedBrowser(url):
+ XCTAssertEqual(url.scheme, "https")
+ XCTAssertEqual(url.host, "example.com")
+ XCTAssertEqual(url.path, "/docs")
+ default:
+ XCTFail("Expected bare domain to be normalized as an HTTPS browser URL")
+ }
+ }
+
+ func testResolvesFileSchemeAsExternal() throws {
+ let target = try XCTUnwrap(resolveTerminalOpenURLTarget("file:///tmp/cmux.txt"))
+ switch target {
+ case let .external(url):
+ XCTAssertTrue(url.isFileURL)
+ XCTAssertEqual(url.path, "/tmp/cmux.txt")
+ default:
+ XCTFail("Expected file URL to open externally")
+ }
+ }
+
+ func testResolvesAbsolutePathAsExternalFileURL() throws {
+ let target = try XCTUnwrap(resolveTerminalOpenURLTarget("/tmp/cmux-path.txt"))
+ switch target {
+ case let .external(url):
+ XCTAssertTrue(url.isFileURL)
+ XCTAssertEqual(url.path, "/tmp/cmux-path.txt")
+ default:
+ XCTFail("Expected absolute file path to open externally")
+ }
+ }
+
+ func testResolvesNonWebSchemeAsExternal() throws {
+ let target = try XCTUnwrap(resolveTerminalOpenURLTarget("mailto:test@example.com"))
+ switch target {
+ case let .external(url):
+ XCTAssertEqual(url.scheme, "mailto")
+ default:
+ XCTFail("Expected non-web scheme to open externally")
+ }
+ }
+
+ func testResolvesHostlessHTTPSAsExternal() throws {
+ let target = try XCTUnwrap(resolveTerminalOpenURLTarget("https:///tmp/cmux.txt"))
+ switch target {
+ case let .external(url):
+ XCTAssertEqual(url.scheme, "https")
+ XCTAssertNil(url.host)
+ XCTAssertEqual(url.path, "/tmp/cmux.txt")
+ default:
+ XCTFail("Expected hostless HTTPS URL to open externally")
+ }
+ }
+}
+
+
+final class TerminalControllerSocketTextChunkTests: XCTestCase {
+ func testSocketTextChunksReturnsSingleChunkForPlainText() {
+ XCTAssertEqual(
+ TerminalController.socketTextChunks("echo hello"),
+ [.text("echo hello")]
+ )
+ }
+
+ func testSocketTextChunksSplitsControlScalars() {
+ XCTAssertEqual(
+ TerminalController.socketTextChunks("abc\rdef\tghi"),
+ [
+ .text("abc"),
+ .control("\r".unicodeScalars.first!),
+ .text("def"),
+ .control("\t".unicodeScalars.first!),
+ .text("ghi")
+ ]
+ )
+ }
+
+ func testSocketTextChunksDoesNotEmitEmptyTextChunksAroundConsecutiveControls() {
+ XCTAssertEqual(
+ TerminalController.socketTextChunks("\r\n\t"),
+ [
+ .control("\r".unicodeScalars.first!),
+ .control("\n".unicodeScalars.first!),
+ .control("\t".unicodeScalars.first!)
+ ]
+ )
+ }
+}
+
+
+final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase {
+ func testImmediateStateUpdateAllowedWhenHostNotInWindow() {
+ XCTAssertTrue(
+ GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate(
+ hostedViewHasSuperview: true,
+ isBoundToCurrentHost: false
+ )
+ )
+ }
+
+ func testImmediateStateUpdateAllowedWhenBoundToCurrentHost() {
+ XCTAssertTrue(
+ GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate(
+ hostedViewHasSuperview: true,
+ isBoundToCurrentHost: true
+ )
+ )
+ }
+
+ func testImmediateStateUpdateSkippedForStaleHostBoundElsewhere() {
+ XCTAssertFalse(
+ GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate(
+ hostedViewHasSuperview: true,
+ isBoundToCurrentHost: false
+ )
+ )
+ }
+
+ func testImmediateStateUpdateAllowedWhenUnboundAndNotAttachedAnywhere() {
+ XCTAssertTrue(
+ GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate(
+ hostedViewHasSuperview: false,
+ isBoundToCurrentHost: false
+ )
+ )
+ }
+}
+
+
+final class TerminalControllerSocketListenerHealthTests: XCTestCase {
+ func testStableSocketBindPermissionFailureFallsBackToUserScopedSocket() {
+ XCTAssertEqual(
+ TerminalController.fallbackSocketPathAfterBindFailure(
+ requestedPath: SocketControlSettings.stableDefaultSocketPath,
+ stage: "bind",
+ errnoCode: EACCES,
+ currentUserID: 501
+ ),
+ SocketControlSettings.userScopedStableSocketPath(currentUserID: 501)
+ )
+ }
+
+ func testNonStableSocketBindFailureDoesNotFallback() {
+ XCTAssertNil(
+ TerminalController.fallbackSocketPathAfterBindFailure(
+ requestedPath: "/tmp/cmux-debug.sock",
+ stage: "bind",
+ errnoCode: EACCES,
+ currentUserID: 501
+ )
+ )
+ }
+
+ private func makeTempSocketPath() -> String {
+ "/tmp/cmux-socket-health-\(UUID().uuidString).sock"
+ }
+
+ private func bindUnixSocket(at path: String) throws -> Int32 {
+ unlink(path)
+
+ let fd = socket(AF_UNIX, SOCK_STREAM, 0)
+ guard fd >= 0 else {
+ throw NSError(
+ domain: NSPOSIXErrorDomain,
+ code: Int(errno),
+ userInfo: [NSLocalizedDescriptionKey: "Failed to create Unix socket"]
+ )
+ }
+
+ var addr = sockaddr_un()
+ addr.sun_family = sa_family_t(AF_UNIX)
+ path.withCString { ptr in
+ withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in
+ let pathBuf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self)
+ strcpy(pathBuf, ptr)
+ }
+ }
+
+ let bindResult = withUnsafePointer(to: &addr) { ptr in
+ ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
+ Darwin.bind(fd, sockaddrPtr, socklen_t(MemoryLayout.size))
+ }
+ }
+ guard bindResult == 0 else {
+ let code = Int(errno)
+ Darwin.close(fd)
+ throw NSError(
+ domain: NSPOSIXErrorDomain,
+ code: code,
+ userInfo: [NSLocalizedDescriptionKey: "Failed to bind Unix socket"]
+ )
+ }
+
+ guard Darwin.listen(fd, 1) == 0 else {
+ let code = Int(errno)
+ Darwin.close(fd)
+ throw NSError(
+ domain: NSPOSIXErrorDomain,
+ code: code,
+ userInfo: [NSLocalizedDescriptionKey: "Failed to listen on Unix socket"]
+ )
+ }
+
+ return fd
+ }
+
+ private func acceptSingleClient(
+ on listenerFD: Int32,
+ handler: @escaping (_ clientFD: Int32) -> Void
+ ) -> XCTestExpectation {
+ let handled = expectation(description: "socket client handled")
+ DispatchQueue.global(qos: .userInitiated).async {
+ var clientAddr = sockaddr_un()
+ var clientAddrLen = socklen_t(MemoryLayout.size)
+ let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr in
+ ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
+ Darwin.accept(listenerFD, sockaddrPtr, &clientAddrLen)
+ }
+ }
+ guard clientFD >= 0 else {
+ handled.fulfill()
+ return
+ }
+ defer {
+ Darwin.close(clientFD)
+ handled.fulfill()
+ }
+ handler(clientFD)
+ }
+ return handled
+ }
+
+ @MainActor
+ func testSocketListenerHealthRecognizesSocketPath() throws {
+ let path = makeTempSocketPath()
+ let fd = try bindUnixSocket(at: path)
+ defer {
+ Darwin.close(fd)
+ unlink(path)
+ }
+
+ let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: path)
+ XCTAssertTrue(health.socketPathExists)
+ XCTAssertFalse(health.isHealthy)
+ }
+
+ @MainActor
+ func testSocketListenerHealthRejectsRegularFile() throws {
+ let path = makeTempSocketPath()
+ let url = URL(fileURLWithPath: path)
+ try "not-a-socket".write(to: url, atomically: true, encoding: .utf8)
+ defer { try? FileManager.default.removeItem(at: url) }
+
+ let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: path)
+ XCTAssertFalse(health.socketPathExists)
+ XCTAssertFalse(health.isHealthy)
+ }
+
+ func testProbeSocketCommandReturnsFirstLineResponse() throws {
+ let path = makeTempSocketPath()
+ let listenerFD = try bindUnixSocket(at: path)
+ defer {
+ Darwin.close(listenerFD)
+ unlink(path)
+ }
+
+ let handled = acceptSingleClient(on: listenerFD) { clientFD in
+ var buffer = [UInt8](repeating: 0, count: 256)
+ _ = read(clientFD, &buffer, buffer.count)
+ let response = "PONG\nextra\n"
+ _ = response.withCString { ptr in
+ write(clientFD, ptr, strlen(ptr))
+ }
+ }
+
+ let response = TerminalController.probeSocketCommand("ping", at: path, timeout: 0.5)
+
+ XCTAssertEqual(response, "PONG")
+ wait(for: [handled], timeout: 1.0)
+ }
+
+ func testProbeSocketCommandTimesOutWithoutPollingUntilServerResponds() throws {
+ let path = makeTempSocketPath()
+ let listenerFD = try bindUnixSocket(at: path)
+ defer {
+ Darwin.close(listenerFD)
+ unlink(path)
+ }
+
+ let releaseServer = DispatchSemaphore(value: 0)
+ let handled = acceptSingleClient(on: listenerFD) { clientFD in
+ var buffer = [UInt8](repeating: 0, count: 256)
+ _ = read(clientFD, &buffer, buffer.count)
+ _ = releaseServer.wait(timeout: .now() + 1.0)
+ }
+
+ let startedAt = Date()
+ let response = TerminalController.probeSocketCommand("ping", at: path, timeout: 0.2)
+ let elapsed = Date().timeIntervalSince(startedAt)
+ releaseServer.signal()
+
+ XCTAssertNil(response)
+ XCTAssertGreaterThanOrEqual(elapsed, 0.18)
+ XCTAssertLessThan(elapsed, 0.8)
+ wait(for: [handled], timeout: 1.0)
+ }
+
+ func testSocketListenerHealthFailureSignalsAreEmptyWhenHealthy() {
+ let health = TerminalController.SocketListenerHealth(
+ isRunning: true,
+ acceptLoopAlive: true,
+ socketPathMatches: true,
+ socketPathExists: true
+ )
+ XCTAssertTrue(health.isHealthy)
+ XCTAssertTrue(health.failureSignals.isEmpty)
+ }
+
+ func testSocketListenerHealthFailureSignalsIncludeAllDetectedProblems() {
+ let health = TerminalController.SocketListenerHealth(
+ isRunning: false,
+ acceptLoopAlive: false,
+ socketPathMatches: false,
+ socketPathExists: false
+ )
+ XCTAssertFalse(health.isHealthy)
+ XCTAssertEqual(
+ health.failureSignals,
+ ["not_running", "accept_loop_dead", "socket_path_mismatch", "socket_missing"]
+ )
+ }
+}
diff --git a/cmuxTests/WindowAndDragTests.swift b/cmuxTests/WindowAndDragTests.swift
new file mode 100644
index 00000000..e618949f
--- /dev/null
+++ b/cmuxTests/WindowAndDragTests.swift
@@ -0,0 +1,1082 @@
+import XCTest
+import AppKit
+import SwiftUI
+import UniformTypeIdentifiers
+import WebKit
+import ObjectiveC.runtime
+import Bonsplit
+import UserNotifications
+
+#if canImport(cmux_DEV)
+@testable import cmux_DEV
+#elseif canImport(cmux)
+@testable import cmux
+#endif
+
+@MainActor
+final class AppDelegateWindowContextRoutingTests: XCTestCase {
+ private func makeMainWindow(id: UUID) -> NSWindow {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ window.identifier = NSUserInterfaceItemIdentifier("cmux.main.\(id.uuidString)")
+ return window
+ }
+
+ func testSynchronizeActiveMainWindowContextPrefersProvidedWindowOverStaleActiveManager() {
+ _ = NSApplication.shared
+ let app = AppDelegate()
+
+ let windowAId = UUID()
+ let windowBId = UUID()
+ let windowA = makeMainWindow(id: windowAId)
+ let windowB = makeMainWindow(id: windowBId)
+ defer {
+ windowA.orderOut(nil)
+ windowB.orderOut(nil)
+ }
+
+ let managerA = TabManager()
+ let managerB = TabManager()
+ app.registerMainWindow(
+ windowA,
+ windowId: windowAId,
+ tabManager: managerA,
+ sidebarState: SidebarState(),
+ sidebarSelectionState: SidebarSelectionState()
+ )
+ app.registerMainWindow(
+ windowB,
+ windowId: windowBId,
+ tabManager: managerB,
+ sidebarState: SidebarState(),
+ sidebarSelectionState: SidebarSelectionState()
+ )
+
+ windowB.makeKeyAndOrderFront(nil)
+ _ = app.synchronizeActiveMainWindowContext(preferredWindow: windowB)
+ XCTAssertTrue(app.tabManager === managerB)
+
+ windowA.makeKeyAndOrderFront(nil)
+ let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: windowA)
+ XCTAssertTrue(resolved === managerA, "Expected provided active window to win over stale active manager")
+ XCTAssertTrue(app.tabManager === managerA)
+ }
+
+ func testSynchronizeActiveMainWindowContextFallsBackToActiveManagerWithoutFocusedWindow() {
+ _ = NSApplication.shared
+ let app = AppDelegate()
+
+ let windowAId = UUID()
+ let windowBId = UUID()
+ let windowA = makeMainWindow(id: windowAId)
+ let windowB = makeMainWindow(id: windowBId)
+ defer {
+ windowA.orderOut(nil)
+ windowB.orderOut(nil)
+ }
+
+ let managerA = TabManager()
+ let managerB = TabManager()
+ app.registerMainWindow(
+ windowA,
+ windowId: windowAId,
+ tabManager: managerA,
+ sidebarState: SidebarState(),
+ sidebarSelectionState: SidebarSelectionState()
+ )
+ app.registerMainWindow(
+ windowB,
+ windowId: windowBId,
+ tabManager: managerB,
+ sidebarState: SidebarState(),
+ sidebarSelectionState: SidebarSelectionState()
+ )
+
+ // Seed active manager and clear focus windows to force fallback routing.
+ windowA.makeKeyAndOrderFront(nil)
+ _ = app.synchronizeActiveMainWindowContext(preferredWindow: windowA)
+ XCTAssertTrue(app.tabManager === managerA)
+ windowA.orderOut(nil)
+ windowB.orderOut(nil)
+
+ let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: nil)
+ XCTAssertTrue(resolved === managerA, "Expected fallback to preserve current active manager instead of arbitrary window")
+ XCTAssertTrue(app.tabManager === managerA)
+ }
+
+ func testSynchronizeActiveMainWindowContextUsesRegisteredWindowEvenIfIdentifierMutates() {
+ _ = NSApplication.shared
+ let app = AppDelegate()
+
+ let windowId = UUID()
+ let window = makeMainWindow(id: windowId)
+ defer { window.orderOut(nil) }
+
+ let manager = TabManager()
+ app.registerMainWindow(
+ window,
+ windowId: windowId,
+ tabManager: manager,
+ sidebarState: SidebarState(),
+ sidebarSelectionState: SidebarSelectionState()
+ )
+
+ // SwiftUI can replace the NSWindow identifier string at runtime.
+ window.identifier = NSUserInterfaceItemIdentifier("SwiftUI.AppWindow.IdentifierChanged")
+
+ let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: window)
+ XCTAssertTrue(resolved === manager, "Expected registered window object identity to win even if identifier string changed")
+ XCTAssertTrue(app.tabManager === manager)
+ }
+
+ func testAddWorkspaceWithoutBringToFrontPreservesActiveWindowAndSelection() {
+ _ = NSApplication.shared
+ let app = AppDelegate()
+
+ let windowAId = UUID()
+ let windowBId = UUID()
+ let windowA = makeMainWindow(id: windowAId)
+ let windowB = makeMainWindow(id: windowBId)
+ defer {
+ windowA.orderOut(nil)
+ windowB.orderOut(nil)
+ }
+
+ let managerA = TabManager()
+ let managerB = TabManager()
+ app.registerMainWindow(
+ windowA,
+ windowId: windowAId,
+ tabManager: managerA,
+ sidebarState: SidebarState(),
+ sidebarSelectionState: SidebarSelectionState()
+ )
+ app.registerMainWindow(
+ windowB,
+ windowId: windowBId,
+ tabManager: managerB,
+ sidebarState: SidebarState(),
+ sidebarSelectionState: SidebarSelectionState()
+ )
+
+ windowA.makeKeyAndOrderFront(nil)
+ _ = app.synchronizeActiveMainWindowContext(preferredWindow: windowA)
+ XCTAssertTrue(app.tabManager === managerA)
+
+ let originalSelectedA = managerA.selectedTabId
+ let originalSelectedB = managerB.selectedTabId
+ let originalTabCountB = managerB.tabs.count
+
+ let createdWorkspaceId = app.addWorkspace(windowId: windowBId, bringToFront: false)
+
+ XCTAssertNotNil(createdWorkspaceId)
+ XCTAssertTrue(app.tabManager === managerA, "Expected non-focus workspace creation to preserve active window routing")
+ XCTAssertEqual(managerA.selectedTabId, originalSelectedA)
+ XCTAssertEqual(managerB.selectedTabId, originalSelectedB, "Expected background workspace creation to preserve selected tab")
+ XCTAssertEqual(managerB.tabs.count, originalTabCountB + 1)
+ XCTAssertTrue(managerB.tabs.contains(where: { $0.id == createdWorkspaceId }))
+ }
+
+ func testApplicationOpenURLsAddsWorkspaceForDroppedFolderURL() throws {
+ _ = NSApplication.shared
+ let app = AppDelegate()
+
+ let windowId = UUID()
+ let window = makeMainWindow(id: windowId)
+ defer { window.orderOut(nil) }
+
+ let manager = TabManager()
+ app.registerMainWindow(
+ window,
+ windowId: windowId,
+ tabManager: manager,
+ sidebarState: SidebarState(),
+ sidebarSelectionState: SidebarSelectionState()
+ )
+
+ window.makeKeyAndOrderFront(nil)
+ _ = app.synchronizeActiveMainWindowContext(preferredWindow: window)
+
+ let defaults = UserDefaults.standard
+ let previousWelcomeShown = defaults.object(forKey: WelcomeSettings.shownKey)
+ defaults.set(true, forKey: WelcomeSettings.shownKey)
+ defer {
+ if let previousWelcomeShown {
+ defaults.set(previousWelcomeShown, forKey: WelcomeSettings.shownKey)
+ } else {
+ defaults.removeObject(forKey: WelcomeSettings.shownKey)
+ }
+ }
+
+ let rootDirectory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ let droppedDirectory = rootDirectory.appendingPathComponent("project", isDirectory: true)
+ try FileManager.default.createDirectory(at: droppedDirectory, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: rootDirectory) }
+
+ let existingWorkspaceIds = Set(manager.tabs.map(\.id))
+
+ app.application(
+ NSApplication.shared,
+ open: [URL(fileURLWithPath: droppedDirectory.path)]
+ )
+
+ let createdWorkspace = manager.tabs.first { !existingWorkspaceIds.contains($0.id) }
+ XCTAssertNotNil(createdWorkspace)
+ XCTAssertEqual(createdWorkspace?.currentDirectory, droppedDirectory.path)
+ }
+}
+
+
+@MainActor
+final class AppDelegateLaunchServicesRegistrationTests: XCTestCase {
+ func testScheduleLaunchServicesRegistrationDefersRegisterWork() {
+ _ = NSApplication.shared
+ let app = AppDelegate()
+
+ var scheduledWork: (@Sendable () -> Void)?
+ var registerCallCount = 0
+
+ app.scheduleLaunchServicesBundleRegistrationForTesting(
+ bundleURL: URL(fileURLWithPath: "/tmp/../tmp/cmux-launch-services-test.app"),
+ scheduler: { work in
+ scheduledWork = work
+ },
+ register: { _ in
+ registerCallCount += 1
+ return noErr
+ }
+ )
+
+ XCTAssertEqual(registerCallCount, 0, "Registration should not run inline on the startup call path")
+ XCTAssertNotNil(scheduledWork, "Registration work should be handed to the scheduler")
+
+ scheduledWork?()
+
+ XCTAssertEqual(registerCallCount, 1)
+ }
+}
+
+
+final class FocusFlashPatternTests: XCTestCase {
+ func testFocusFlashPatternMatchesTerminalDoublePulseShape() {
+ XCTAssertEqual(FocusFlashPattern.values, [0, 1, 0, 1, 0])
+ XCTAssertEqual(FocusFlashPattern.keyTimes, [0, 0.25, 0.5, 0.75, 1])
+ XCTAssertEqual(FocusFlashPattern.duration, 0.9, accuracy: 0.0001)
+ XCTAssertEqual(FocusFlashPattern.curves, [.easeOut, .easeIn, .easeOut, .easeIn])
+ XCTAssertEqual(FocusFlashPattern.ringInset, 6, accuracy: 0.0001)
+ XCTAssertEqual(FocusFlashPattern.ringCornerRadius, 10, accuracy: 0.0001)
+ }
+
+ func testFocusFlashPatternSegmentsCoverFullDoublePulseTimeline() {
+ let segments = FocusFlashPattern.segments
+ XCTAssertEqual(segments.count, 4)
+
+ XCTAssertEqual(segments[0].delay, 0.0, accuracy: 0.0001)
+ XCTAssertEqual(segments[0].duration, 0.225, accuracy: 0.0001)
+ XCTAssertEqual(segments[0].targetOpacity, 1, accuracy: 0.0001)
+ XCTAssertEqual(segments[0].curve, .easeOut)
+
+ XCTAssertEqual(segments[1].delay, 0.225, accuracy: 0.0001)
+ XCTAssertEqual(segments[1].duration, 0.225, accuracy: 0.0001)
+ XCTAssertEqual(segments[1].targetOpacity, 0, accuracy: 0.0001)
+ XCTAssertEqual(segments[1].curve, .easeIn)
+
+ XCTAssertEqual(segments[2].delay, 0.45, accuracy: 0.0001)
+ XCTAssertEqual(segments[2].duration, 0.225, accuracy: 0.0001)
+ XCTAssertEqual(segments[2].targetOpacity, 1, accuracy: 0.0001)
+ XCTAssertEqual(segments[2].curve, .easeOut)
+
+ XCTAssertEqual(segments[3].delay, 0.675, accuracy: 0.0001)
+ XCTAssertEqual(segments[3].duration, 0.225, accuracy: 0.0001)
+ XCTAssertEqual(segments[3].targetOpacity, 0, accuracy: 0.0001)
+ XCTAssertEqual(segments[3].curve, .easeIn)
+ }
+}
+
+
+@MainActor
+final class InternalTabDragConfigurationTests: XCTestCase {
+ func testDisablesExternalOperationsForInternalTabDrags() throws {
+ guard #available(macOS 26.0, *) else {
+ throw XCTSkip("Requires macOS 26 drag configuration APIs")
+ }
+
+ let configuration = InternalTabDragConfigurationProvider.value
+ let withinApp = try dragConfigurationOperationsSnapshot(from: configuration.operationsWithinApp)
+ let outsideApp = try dragConfigurationOperationsSnapshot(from: configuration.operationsOutsideApp)
+
+ XCTAssertEqual(
+ withinApp,
+ DragConfigurationOperationsSnapshot(
+ allowCopy: false,
+ allowMove: true,
+ allowDelete: false,
+ allowAlias: false
+ )
+ )
+
+ XCTAssertEqual(
+ outsideApp,
+ DragConfigurationOperationsSnapshot(
+ allowCopy: false,
+ allowMove: false,
+ allowDelete: false,
+ allowAlias: false
+ )
+ )
+ }
+}
+
+
+@MainActor
+final class InternalTabDragBundleDeclarationTests: XCTestCase {
+ private func exportedTypeIdentifiers(bundle: Bundle) -> Set {
+ let declarations = (bundle.object(forInfoDictionaryKey: "UTExportedTypeDeclarations") as? [[String: Any]]) ?? []
+ return Set(declarations.compactMap { $0["UTTypeIdentifier"] as? String })
+ }
+
+ func testAppBundleExportsInternalDragTypes() {
+ let exported = exportedTypeIdentifiers(bundle: Bundle(for: AppDelegate.self))
+
+ XCTAssertTrue(
+ exported.contains("com.splittabbar.tabtransfer"),
+ "Expected app bundle to export bonsplit tab-transfer type, got \(exported)"
+ )
+ XCTAssertTrue(
+ exported.contains("com.cmux.sidebar-tab-reorder"),
+ "Expected app bundle to export sidebar tab-reorder type, got \(exported)"
+ )
+ }
+}
+#endif
+
+
+@MainActor
+final class WindowDragHandleHitTests: XCTestCase {
+ private final class CapturingView: NSView {
+ override func hitTest(_ point: NSPoint) -> NSView? {
+ bounds.contains(point) ? self : nil
+ }
+ }
+
+ private final class HostContainerView: NSView {}
+ private final class BlockingTopHitContainerView: NSView {
+ override func hitTest(_ point: NSPoint) -> NSView? {
+ bounds.contains(point) ? self : nil
+ }
+ }
+ private final class PassThroughProbeView: NSView {
+ var onHitTest: (() -> Void)?
+
+ override func hitTest(_ point: NSPoint) -> NSView? {
+ guard bounds.contains(point) else { return nil }
+ onHitTest?()
+ return nil
+ }
+ }
+ private final class PassiveHostContainerView: NSView {
+ override func hitTest(_ point: NSPoint) -> NSView? {
+ guard bounds.contains(point) else { return nil }
+ return super.hitTest(point) ?? self
+ }
+ }
+
+ private final class MutatingSiblingView: NSView {
+ weak var container: NSView?
+ private var didMutate = false
+
+ override func hitTest(_ point: NSPoint) -> NSView? {
+ guard bounds.contains(point) else { return nil }
+ guard !didMutate, let container else { return nil }
+ didMutate = true
+ let transient = NSView(frame: .zero)
+ container.addSubview(transient)
+ transient.removeFromSuperview()
+ return nil
+ }
+ }
+
+ private final class ReentrantDragHandleView: NSView {
+ override func hitTest(_ point: NSPoint) -> NSView? {
+ let shouldCapture = windowDragHandleShouldCaptureHit(point, in: self, eventType: .leftMouseDown, eventWindow: self.window)
+ return shouldCapture ? self : nil
+ }
+ }
+
+ /// A sibling view whose hitTest re-enters windowDragHandleShouldCaptureHit,
+ /// simulating the crash path where sibling.hitTest triggers a SwiftUI layout
+ /// pass that calls back into the drag handle's hit resolution.
+ private final class ReentrantSiblingView: NSView {
+ weak var dragHandle: NSView?
+ var reenteredResult: Bool?
+
+ override func hitTest(_ point: NSPoint) -> NSView? {
+ guard bounds.contains(point), let dragHandle else { return nil }
+ // Simulate the re-entry: during sibling hit test, SwiftUI layout
+ // calls windowDragHandleShouldCaptureHit on the drag handle again.
+ reenteredResult = windowDragHandleShouldCaptureHit(
+ point, in: dragHandle, eventType: .leftMouseDown, eventWindow: dragHandle.window
+ )
+ return nil
+ }
+ }
+
+ func testDragHandleCapturesHitWhenNoSiblingClaimsPoint() {
+ let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
+ let dragHandle = NSView(frame: container.bounds)
+ container.addSubview(dragHandle)
+
+ XCTAssertTrue(
+ windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle, eventType: .leftMouseDown),
+ "Empty titlebar space should drag the window"
+ )
+ }
+
+ func testDragHandleYieldsWhenSiblingClaimsPoint() {
+ let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
+ let dragHandle = NSView(frame: container.bounds)
+ container.addSubview(dragHandle)
+
+ let folderIconHost = CapturingView(frame: NSRect(x: 10, y: 10, width: 16, height: 16))
+ container.addSubview(folderIconHost)
+
+ XCTAssertFalse(
+ windowDragHandleShouldCaptureHit(NSPoint(x: 14, y: 14), in: dragHandle, eventType: .leftMouseDown),
+ "Interactive titlebar controls should receive the mouse event"
+ )
+ XCTAssertTrue(windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle, eventType: .leftMouseDown))
+ }
+
+ func testDragHandleIgnoresHiddenSiblingWhenResolvingHit() {
+ let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
+ let dragHandle = NSView(frame: container.bounds)
+ container.addSubview(dragHandle)
+
+ let hidden = CapturingView(frame: NSRect(x: 10, y: 10, width: 16, height: 16))
+ hidden.isHidden = true
+ container.addSubview(hidden)
+
+ XCTAssertTrue(windowDragHandleShouldCaptureHit(NSPoint(x: 14, y: 14), in: dragHandle, eventType: .leftMouseDown))
+ }
+
+ func testDragHandleDoesNotCaptureOutsideBounds() {
+ let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
+ let dragHandle = NSView(frame: container.bounds)
+ container.addSubview(dragHandle)
+
+ XCTAssertFalse(windowDragHandleShouldCaptureHit(NSPoint(x: 240, y: 18), in: dragHandle, eventType: .leftMouseDown))
+ }
+
+ func testDragHandleSkipsCaptureForPassivePointerEvents() {
+ let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
+ let dragHandle = NSView(frame: container.bounds)
+ container.addSubview(dragHandle)
+
+ let point = NSPoint(x: 180, y: 18)
+ XCTAssertFalse(windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: .mouseMoved))
+ XCTAssertFalse(windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: .cursorUpdate))
+ XCTAssertFalse(windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: nil))
+ XCTAssertTrue(windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: .leftMouseDown))
+ }
+
+ func testDragHandleSkipsForeignLeftMouseDownDuringLaunch() {
+ let point = NSPoint(x: 180, y: 18)
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 220, height: 36),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let container = NSView(frame: contentView.bounds)
+ container.autoresizingMask = [.width, .height]
+ contentView.addSubview(container)
+
+ let dragHandle = NSView(frame: container.bounds)
+ dragHandle.autoresizingMask = [.width, .height]
+ container.addSubview(dragHandle)
+
+ let foreignWindow = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 220, height: 36),
+ styleMask: [.titled],
+ backing: .buffered,
+ defer: false
+ )
+ defer { foreignWindow.orderOut(nil) }
+
+ XCTAssertFalse(
+ windowDragHandleShouldCaptureHit(
+ point,
+ in: dragHandle,
+ eventType: .leftMouseDown,
+ eventWindow: nil
+ ),
+ "Launch activation events without a matching window should not trigger drag-handle hierarchy walk"
+ )
+
+ XCTAssertFalse(
+ windowDragHandleShouldCaptureHit(
+ point,
+ in: dragHandle,
+ eventType: .leftMouseDown,
+ eventWindow: foreignWindow
+ ),
+ "Left mouse-down events for a different window should be treated as passive"
+ )
+
+ XCTAssertTrue(
+ windowDragHandleShouldCaptureHit(
+ point,
+ in: dragHandle,
+ eventType: .leftMouseDown,
+ eventWindow: window
+ ),
+ "Left mouse-down events for this window should still capture empty titlebar space"
+ )
+ }
+
+ func testPassiveHostingTopHitClassification() {
+ XCTAssertTrue(windowDragHandleShouldTreatTopHitAsPassiveHost(HostContainerView(frame: .zero)))
+ XCTAssertFalse(windowDragHandleShouldTreatTopHitAsPassiveHost(NSButton(frame: .zero)))
+ }
+
+ func testDragHandleIgnoresPassiveHostSiblingHit() {
+ let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
+ let dragHandle = NSView(frame: container.bounds)
+ container.addSubview(dragHandle)
+
+ let passiveHost = PassiveHostContainerView(frame: container.bounds)
+ container.addSubview(passiveHost)
+
+ XCTAssertTrue(
+ windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle, eventType: .leftMouseDown),
+ "Passive host wrappers should not block titlebar drag capture"
+ )
+ }
+
+ func testDragHandleRespectsInteractiveChildInsidePassiveHost() {
+ let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
+ let dragHandle = NSView(frame: container.bounds)
+ container.addSubview(dragHandle)
+
+ let passiveHost = PassiveHostContainerView(frame: container.bounds)
+ let folderControl = CapturingView(frame: NSRect(x: 10, y: 10, width: 16, height: 16))
+ passiveHost.addSubview(folderControl)
+ container.addSubview(passiveHost)
+
+ XCTAssertFalse(
+ windowDragHandleShouldCaptureHit(NSPoint(x: 14, y: 14), in: dragHandle, eventType: .leftMouseDown),
+ "Interactive controls inside passive host wrappers should still receive hits"
+ )
+ }
+
+ func testTopHitResolutionStateIsScopedPerWindow() {
+ let point = NSPoint(x: 100, y: 18)
+
+ let outerWindow = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 220, height: 36),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { outerWindow.orderOut(nil) }
+ guard let outerContentView = outerWindow.contentView else {
+ XCTFail("Expected outer content view")
+ return
+ }
+ let outerContainer = NSView(frame: outerContentView.bounds)
+ outerContainer.autoresizingMask = [.width, .height]
+ outerContentView.addSubview(outerContainer)
+ let outerDragHandle = NSView(frame: outerContainer.bounds)
+ outerDragHandle.autoresizingMask = [.width, .height]
+ outerContainer.addSubview(outerDragHandle)
+
+ let nestedWindow = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 220, height: 36),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { nestedWindow.orderOut(nil) }
+ guard let nestedContentView = nestedWindow.contentView else {
+ XCTFail("Expected nested content view")
+ return
+ }
+ let nestedContainer = BlockingTopHitContainerView(frame: nestedContentView.bounds)
+ nestedContainer.autoresizingMask = [.width, .height]
+ nestedContentView.addSubview(nestedContainer)
+ let nestedDragHandle = NSView(frame: nestedContainer.bounds)
+ nestedDragHandle.autoresizingMask = [.width, .height]
+ nestedContainer.addSubview(nestedDragHandle)
+
+ XCTAssertFalse(
+ windowDragHandleShouldCaptureHit(point, in: nestedDragHandle, eventType: .leftMouseDown, eventWindow: nestedWindow),
+ "Nested window drag handle should be blocked by top-hit titlebar container"
+ )
+
+ var nestedCaptureResult: Bool?
+ let probe = PassThroughProbeView(frame: outerContainer.bounds)
+ probe.autoresizingMask = [.width, .height]
+ probe.onHitTest = {
+ nestedCaptureResult = windowDragHandleShouldCaptureHit(point, in: nestedDragHandle, eventType: .leftMouseDown, eventWindow: nestedWindow)
+ }
+ outerContainer.addSubview(probe)
+
+ _ = windowDragHandleShouldCaptureHit(point, in: outerDragHandle, eventType: .leftMouseDown, eventWindow: outerWindow)
+
+ XCTAssertEqual(
+ nestedCaptureResult,
+ false,
+ "Top-hit recursion in one window must not disable top-hit resolution in another window"
+ )
+ }
+
+ func testDragHandleRemainsStableWhenSiblingMutatesSubviewsDuringHitTest() {
+ let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
+ let dragHandle = NSView(frame: container.bounds)
+ container.addSubview(dragHandle)
+
+ let mutatingSibling = MutatingSiblingView(frame: container.bounds)
+ mutatingSibling.container = container
+ container.addSubview(mutatingSibling)
+
+ XCTAssertTrue(
+ windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle, eventType: .leftMouseDown),
+ "Subview mutations during hit testing should not crash or break drag-handle capture"
+ )
+ }
+
+ func testDragHandleSiblingHitTestReentrancyDoesNotCrash() {
+ let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
+ let dragHandle = NSView(frame: container.bounds)
+ container.addSubview(dragHandle)
+
+ let reentrantSibling = ReentrantSiblingView(frame: container.bounds)
+ reentrantSibling.dragHandle = dragHandle
+ container.addSubview(reentrantSibling)
+
+ // The outer call enters the sibling walk, which calls
+ // reentrantSibling.hitTest(), which re-enters
+ // windowDragHandleShouldCaptureHit. Without the re-entrancy guard
+ // this would trigger a Swift exclusive-access violation (SIGABRT).
+ let outerResult = windowDragHandleShouldCaptureHit(
+ NSPoint(x: 110, y: 18), in: dragHandle, eventType: .leftMouseDown
+ )
+ XCTAssertTrue(outerResult, "Outer call should still capture when sibling returns nil")
+ XCTAssertEqual(
+ reentrantSibling.reenteredResult, false,
+ "Re-entrant call should bail out (return false) instead of crashing"
+ )
+ }
+
+ func testDragHandleTopHitResolutionSurvivesSameWindowReentrancy() {
+ let point = NSPoint(x: 180, y: 18)
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 220, height: 36),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let container = NSView(frame: contentView.bounds)
+ container.autoresizingMask = [.width, .height]
+ contentView.addSubview(container)
+
+ let dragHandle = ReentrantDragHandleView(frame: container.bounds)
+ dragHandle.autoresizingMask = [.width, .height]
+ container.addSubview(dragHandle)
+
+ XCTAssertTrue(
+ windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: .leftMouseDown, eventWindow: window),
+ "Reentrant same-window top-hit resolution should not trigger exclusivity crashes"
+ )
+ }
+}
+
+#if DEBUG
+
+
+@MainActor
+final class DraggableFolderHitTests: XCTestCase {
+ func testFolderHitTestReturnsContainerWhenInsideBounds() {
+ let folderView = DraggableFolderNSView(directory: "/tmp")
+ folderView.frame = NSRect(x: 0, y: 0, width: 16, height: 16)
+
+ guard let hit = folderView.hitTest(NSPoint(x: 8, y: 8)) else {
+ XCTFail("Expected folder icon to capture inside hit")
+ return
+ }
+ XCTAssertTrue(hit === folderView)
+ }
+
+ func testFolderHitTestReturnsNilOutsideBounds() {
+ let folderView = DraggableFolderNSView(directory: "/tmp")
+ folderView.frame = NSRect(x: 0, y: 0, width: 16, height: 16)
+
+ XCTAssertNil(folderView.hitTest(NSPoint(x: 20, y: 8)))
+ }
+
+ func testFolderIconDisablesWindowMoveBehavior() {
+ let folderView = DraggableFolderNSView(directory: "/tmp")
+ XCTAssertFalse(folderView.mouseDownCanMoveWindow)
+ }
+}
+
+
+@MainActor
+final class TitlebarLeadingInsetPassthroughViewTests: XCTestCase {
+ func testLeadingInsetViewDoesNotParticipateInHitTesting() {
+ let view = TitlebarLeadingInsetPassthroughView(frame: NSRect(x: 0, y: 0, width: 200, height: 40))
+ XCTAssertNil(view.hitTest(NSPoint(x: 20, y: 10)))
+ }
+
+ func testLeadingInsetViewCannotMoveWindowViaMouseDown() {
+ let view = TitlebarLeadingInsetPassthroughView(frame: NSRect(x: 0, y: 0, width: 200, height: 40))
+ XCTAssertFalse(view.mouseDownCanMoveWindow)
+ }
+}
+
+
+@MainActor
+final class FolderWindowMoveSuppressionTests: XCTestCase {
+ private func makeWindow() -> NSWindow {
+ NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 320, height: 180),
+ styleMask: [.titled, .closable, .miniaturizable, .resizable],
+ backing: .buffered,
+ defer: false
+ )
+ }
+
+ func testSuppressionDisablesMovableWindow() {
+ let window = makeWindow()
+ window.isMovable = true
+
+ let previous = temporarilyDisableWindowDragging(window: window)
+
+ XCTAssertEqual(previous, true)
+ XCTAssertFalse(window.isMovable)
+ }
+
+ func testSuppressionPreservesAlreadyImmovableWindow() {
+ let window = makeWindow()
+ window.isMovable = false
+
+ let previous = temporarilyDisableWindowDragging(window: window)
+
+ XCTAssertEqual(previous, false)
+ XCTAssertFalse(window.isMovable)
+ }
+
+ func testRestoreAppliesPreviousMovableState() {
+ let window = makeWindow()
+ window.isMovable = false
+
+ restoreWindowDragging(window: window, previousMovableState: true)
+ XCTAssertTrue(window.isMovable)
+
+ restoreWindowDragging(window: window, previousMovableState: false)
+ XCTAssertFalse(window.isMovable)
+ }
+
+ func testWindowDragSuppressionDepthLifecycle() {
+ let window = makeWindow()
+ XCTAssertEqual(windowDragSuppressionDepth(window: window), 0)
+ XCTAssertFalse(isWindowDragSuppressed(window: window))
+
+ XCTAssertEqual(beginWindowDragSuppression(window: window), 1)
+ XCTAssertEqual(windowDragSuppressionDepth(window: window), 1)
+ XCTAssertTrue(isWindowDragSuppressed(window: window))
+
+ XCTAssertEqual(endWindowDragSuppression(window: window), 0)
+ XCTAssertEqual(windowDragSuppressionDepth(window: window), 0)
+ XCTAssertFalse(isWindowDragSuppressed(window: window))
+ }
+
+ func testWindowDragSuppressionIsReferenceCounted() {
+ let window = makeWindow()
+ XCTAssertEqual(beginWindowDragSuppression(window: window), 1)
+ XCTAssertEqual(beginWindowDragSuppression(window: window), 2)
+ XCTAssertEqual(windowDragSuppressionDepth(window: window), 2)
+ XCTAssertTrue(isWindowDragSuppressed(window: window))
+
+ XCTAssertEqual(endWindowDragSuppression(window: window), 1)
+ XCTAssertEqual(windowDragSuppressionDepth(window: window), 1)
+ XCTAssertTrue(isWindowDragSuppressed(window: window))
+
+ XCTAssertEqual(endWindowDragSuppression(window: window), 0)
+ XCTAssertEqual(windowDragSuppressionDepth(window: window), 0)
+ XCTAssertFalse(isWindowDragSuppressed(window: window))
+ }
+
+ func testTemporaryWindowMovableEnableRestoresImmovableWindow() {
+ let window = makeWindow()
+ window.isMovable = false
+
+ let previous = withTemporaryWindowMovableEnabled(window: window) {
+ XCTAssertTrue(window.isMovable)
+ }
+
+ XCTAssertEqual(previous, false)
+ XCTAssertFalse(window.isMovable)
+ }
+
+ func testTemporaryWindowMovableEnablePreservesMovableWindow() {
+ let window = makeWindow()
+ window.isMovable = true
+
+ let previous = withTemporaryWindowMovableEnabled(window: window) {
+ XCTAssertTrue(window.isMovable)
+ }
+
+ XCTAssertEqual(previous, true)
+ XCTAssertTrue(window.isMovable)
+ }
+}
+
+
+@MainActor
+final class WindowMoveSuppressionHitPathTests: XCTestCase {
+ private func makeWindowWithContentView() -> (NSWindow, NSView) {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 320, height: 180),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ let contentView = NSView(frame: window.contentRect(forFrameRect: window.frame))
+ window.contentView = contentView
+ return (window, contentView)
+ }
+
+ private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent {
+ guard let event = NSEvent.mouseEvent(
+ with: type,
+ location: location,
+ modifierFlags: [],
+ timestamp: ProcessInfo.processInfo.systemUptime,
+ windowNumber: window.windowNumber,
+ context: nil,
+ eventNumber: 0,
+ clickCount: 1,
+ pressure: 1.0
+ ) else {
+ fatalError("Failed to create \(type) mouse event")
+ }
+ return event
+ }
+
+ func testSuppressionHitPathRecognizesFolderView() {
+ let folderView = DraggableFolderNSView(directory: "/tmp")
+ XCTAssertTrue(shouldSuppressWindowMoveForFolderDrag(hitView: folderView))
+ }
+
+ func testSuppressionHitPathRecognizesDescendantOfFolderView() {
+ let folderView = DraggableFolderNSView(directory: "/tmp")
+ let child = NSView(frame: .zero)
+ folderView.addSubview(child)
+ XCTAssertTrue(shouldSuppressWindowMoveForFolderDrag(hitView: child))
+ }
+
+ func testSuppressionHitPathIgnoresUnrelatedViews() {
+ XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(hitView: NSView(frame: .zero)))
+ XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(hitView: nil))
+ }
+
+ func testSuppressionEventPathRecognizesFolderHitInsideWindow() {
+ let (window, contentView) = makeWindowWithContentView()
+ window.isMovable = true
+ let folderView = DraggableFolderNSView(directory: "/tmp")
+ folderView.frame = NSRect(x: 10, y: 10, width: 16, height: 16)
+ contentView.addSubview(folderView)
+
+ let event = makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 14, y: 14), window: window)
+
+ XCTAssertTrue(shouldSuppressWindowMoveForFolderDrag(window: window, event: event))
+ }
+
+ func testSuppressionEventPathRejectsNonFolderAndNonMouseDownEvents() {
+ let (window, contentView) = makeWindowWithContentView()
+ window.isMovable = true
+ let plainView = NSView(frame: NSRect(x: 0, y: 0, width: 40, height: 40))
+ contentView.addSubview(plainView)
+
+ let down = makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 20, y: 20), window: window)
+ XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(window: window, event: down))
+
+ let dragged = makeMouseEvent(type: .leftMouseDragged, location: NSPoint(x: 20, y: 20), window: window)
+ XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(window: window, event: dragged))
+ }
+}
+
+
+@MainActor
+final class FileDropOverlayViewTests: XCTestCase {
+ private func realizeWindowLayout(_ window: NSWindow) {
+ window.makeKeyAndOrderFront(nil)
+ window.displayIfNeeded()
+ window.contentView?.layoutSubtreeIfNeeded()
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+ window.contentView?.layoutSubtreeIfNeeded()
+ }
+
+ func testOverlayResolvesPortalHostedBrowserWebViewForFileDrops() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 420, height: 280),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer {
+ NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window)
+ window.orderOut(nil)
+ }
+ realizeWindowLayout(window)
+
+ guard let contentView = window.contentView,
+ let container = contentView.superview else {
+ XCTFail("Expected content container")
+ return
+ }
+
+ let anchor = NSView(frame: NSRect(x: 40, y: 36, width: 220, height: 150))
+ contentView.addSubview(anchor)
+
+ let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
+ BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true)
+ BrowserWindowPortalRegistry.synchronizeForAnchor(anchor)
+
+ let overlay = FileDropOverlayView(frame: container.bounds)
+ overlay.autoresizingMask = [.width, .height]
+ container.addSubview(overlay, positioned: .above, relativeTo: nil)
+
+ let point = anchor.convert(
+ NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY),
+ to: nil
+ )
+ XCTAssertTrue(
+ overlay.webViewUnderPoint(point) === webView,
+ "File-drop overlay should resolve portal-hosted browser panes so Finder uploads still reach WKWebView"
+ )
+ }
+}
+
+
+@MainActor
+final class MarkdownPanelPointerObserverViewTests: XCTestCase {
+ private func makeWindow() -> NSWindow {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 320, height: 180),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ window.makeKeyAndOrderFront(nil)
+ window.displayIfNeeded()
+ window.contentView?.layoutSubtreeIfNeeded()
+ return window
+ }
+
+ private func makeMouseEvent(
+ type: NSEvent.EventType,
+ location: NSPoint,
+ window: NSWindow,
+ eventNumber: Int = 1
+ ) -> NSEvent {
+ guard let event = NSEvent.mouseEvent(
+ with: type,
+ location: location,
+ modifierFlags: [],
+ timestamp: ProcessInfo.processInfo.systemUptime,
+ windowNumber: window.windowNumber,
+ context: nil,
+ eventNumber: eventNumber,
+ clickCount: 1,
+ pressure: 1.0
+ ) else {
+ fatalError("Expected to create mouse event")
+ }
+ return event
+ }
+
+ func testObserverTriggersFocusForVisibleLeftClickInsideBounds() {
+ let window = makeWindow()
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let overlay = MarkdownPanelPointerObserverView(frame: contentView.bounds)
+ overlay.autoresizingMask = [.width, .height]
+ let focusExpectation = expectation(description: "observer forwards focus callback")
+ var pointerDownCount = 0
+ overlay.onPointerDown = {
+ pointerDownCount += 1
+ focusExpectation.fulfill()
+ }
+ contentView.addSubview(overlay)
+
+ _ = overlay.handleEventIfNeeded(
+ makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 60, y: 60), window: window)
+ )
+ wait(for: [focusExpectation], timeout: 1.0)
+
+ XCTAssertEqual(pointerDownCount, 1)
+ }
+
+ func testObserverIgnoresOutsideOrForeignWindowClicks() {
+ let window = makeWindow()
+ defer { window.orderOut(nil) }
+ let otherWindow = makeWindow()
+ defer { otherWindow.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let overlay = MarkdownPanelPointerObserverView(frame: contentView.bounds)
+ overlay.autoresizingMask = [.width, .height]
+ let noFocusExpectation = expectation(description: "observer ignores invalid clicks")
+ noFocusExpectation.isInverted = true
+ var pointerDownCount = 0
+ overlay.onPointerDown = {
+ pointerDownCount += 1
+ noFocusExpectation.fulfill()
+ }
+ contentView.addSubview(overlay)
+
+ _ = overlay.handleEventIfNeeded(
+ makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 400, y: 400), window: window)
+ )
+ _ = overlay.handleEventIfNeeded(
+ makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 60, y: 60), window: otherWindow, eventNumber: 2)
+ )
+ _ = overlay.handleEventIfNeeded(
+ makeMouseEvent(type: .leftMouseDragged, location: NSPoint(x: 60, y: 60), window: window, eventNumber: 3)
+ )
+ wait(for: [noFocusExpectation], timeout: 0.1)
+
+ XCTAssertEqual(pointerDownCount, 0)
+ }
+
+ func testObserverDoesNotParticipateInHitTesting() {
+ let overlay = MarkdownPanelPointerObserverView(frame: NSRect(x: 0, y: 0, width: 200, height: 100))
+ XCTAssertNil(overlay.hitTest(NSPoint(x: 40, y: 30)))
+ }
+}
diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift
new file mode 100644
index 00000000..5bd83682
--- /dev/null
+++ b/cmuxTests/WorkspaceUnitTests.swift
@@ -0,0 +1,1899 @@
+import XCTest
+import AppKit
+import SwiftUI
+import UniformTypeIdentifiers
+import WebKit
+import ObjectiveC.runtime
+import Bonsplit
+import UserNotifications
+
+#if canImport(cmux_DEV)
+@testable import cmux_DEV
+#elseif canImport(cmux)
+@testable import cmux
+#endif
+
+@MainActor
+func makeTemporaryBrowserProfile(named prefix: String) throws -> BrowserProfileDefinition {
+ try XCTUnwrap(
+ BrowserProfileStore.shared.createProfile(
+ named: "\(prefix)-\(UUID().uuidString)"
+ )
+ )
+}
+
+final class SidebarSelectedWorkspaceColorTests: XCTestCase {
+ func testLightModeUsesConfiguredSelectedWorkspaceBackgroundColor() {
+ guard let color = sidebarSelectedWorkspaceBackgroundNSColor(for: .light).usingColorSpace(.sRGB) else {
+ XCTFail("Expected sRGB-convertible color")
+ return
+ }
+
+ XCTAssertEqual(color.redComponent, 0, accuracy: 0.001)
+ XCTAssertEqual(color.greenComponent, 136.0 / 255.0, accuracy: 0.001)
+ XCTAssertEqual(color.blueComponent, 1.0, accuracy: 0.001)
+ XCTAssertEqual(color.alphaComponent, 1.0, accuracy: 0.001)
+ }
+
+ func testDarkModeUsesConfiguredSelectedWorkspaceBackgroundColor() {
+ guard let color = sidebarSelectedWorkspaceBackgroundNSColor(for: .dark).usingColorSpace(.sRGB) else {
+ XCTFail("Expected sRGB-convertible color")
+ return
+ }
+
+ XCTAssertEqual(color.redComponent, 0, accuracy: 0.001)
+ XCTAssertEqual(color.greenComponent, 145.0 / 255.0, accuracy: 0.001)
+ XCTAssertEqual(color.blueComponent, 1.0, accuracy: 0.001)
+ XCTAssertEqual(color.alphaComponent, 1.0, accuracy: 0.001)
+ }
+
+ func testSelectedWorkspaceForegroundAlwaysUsesWhiteWithRequestedOpacity() {
+ guard let color = sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.65).usingColorSpace(.sRGB) else {
+ XCTFail("Expected sRGB-convertible color")
+ return
+ }
+
+ XCTAssertEqual(color.redComponent, 1.0, accuracy: 0.001)
+ XCTAssertEqual(color.greenComponent, 1.0, accuracy: 0.001)
+ XCTAssertEqual(color.blueComponent, 1.0, accuracy: 0.001)
+ XCTAssertEqual(color.alphaComponent, 0.65, accuracy: 0.001)
+ }
+}
+
+
+final class WorkspaceRenameShortcutDefaultsTests: XCTestCase {
+ func testRenameTabShortcutDefaultsAndMetadata() {
+ XCTAssertEqual(KeyboardShortcutSettings.Action.renameTab.label, "Rename Tab")
+ XCTAssertEqual(KeyboardShortcutSettings.Action.renameTab.defaultsKey, "shortcut.renameTab")
+
+ let shortcut = KeyboardShortcutSettings.Action.renameTab.defaultShortcut
+ XCTAssertEqual(shortcut.key, "r")
+ XCTAssertTrue(shortcut.command)
+ XCTAssertFalse(shortcut.shift)
+ XCTAssertFalse(shortcut.option)
+ XCTAssertFalse(shortcut.control)
+ }
+
+ func testCloseWindowShortcutDefaultsAndMetadata() {
+ XCTAssertEqual(KeyboardShortcutSettings.Action.closeWindow.label, "Close Window")
+ XCTAssertEqual(KeyboardShortcutSettings.Action.closeWindow.defaultsKey, "shortcut.closeWindow")
+
+ let shortcut = KeyboardShortcutSettings.Action.closeWindow.defaultShortcut
+ XCTAssertEqual(shortcut.key, "w")
+ XCTAssertTrue(shortcut.command)
+ XCTAssertFalse(shortcut.shift)
+ XCTAssertFalse(shortcut.option)
+ XCTAssertTrue(shortcut.control)
+ }
+
+ func testRenameWorkspaceShortcutDefaultsAndMetadata() {
+ XCTAssertEqual(KeyboardShortcutSettings.Action.renameWorkspace.label, "Rename Workspace")
+ XCTAssertEqual(KeyboardShortcutSettings.Action.renameWorkspace.defaultsKey, "shortcut.renameWorkspace")
+
+ let shortcut = KeyboardShortcutSettings.Action.renameWorkspace.defaultShortcut
+ XCTAssertEqual(shortcut.key, "r")
+ XCTAssertTrue(shortcut.command)
+ XCTAssertTrue(shortcut.shift)
+ XCTAssertFalse(shortcut.option)
+ XCTAssertFalse(shortcut.control)
+ }
+
+ func testRenameWorkspaceShortcutConvertsToMenuShortcut() {
+ let shortcut = KeyboardShortcutSettings.Action.renameWorkspace.defaultShortcut
+ XCTAssertNotNil(shortcut.keyEquivalent)
+ XCTAssertTrue(shortcut.eventModifiers.contains(.command))
+ XCTAssertTrue(shortcut.eventModifiers.contains(.shift))
+ XCTAssertFalse(shortcut.eventModifiers.contains(.option))
+ XCTAssertFalse(shortcut.eventModifiers.contains(.control))
+ }
+
+ func testCloseWorkspaceShortcutDefaultsAndMetadata() {
+ XCTAssertEqual(KeyboardShortcutSettings.Action.closeWorkspace.label, "Close Workspace")
+ XCTAssertEqual(KeyboardShortcutSettings.Action.closeWorkspace.defaultsKey, "shortcut.closeWorkspace")
+
+ let shortcut = KeyboardShortcutSettings.Action.closeWorkspace.defaultShortcut
+ XCTAssertEqual(shortcut.key, "w")
+ XCTAssertTrue(shortcut.command)
+ XCTAssertTrue(shortcut.shift)
+ XCTAssertFalse(shortcut.option)
+ XCTAssertFalse(shortcut.control)
+ }
+
+ func testCloseWorkspaceShortcutConvertsToMenuShortcut() {
+ let shortcut = KeyboardShortcutSettings.Action.closeWorkspace.defaultShortcut
+ XCTAssertNotNil(shortcut.keyEquivalent)
+ XCTAssertTrue(shortcut.eventModifiers.contains(.command))
+ XCTAssertTrue(shortcut.eventModifiers.contains(.shift))
+ XCTAssertFalse(shortcut.eventModifiers.contains(.option))
+ XCTAssertFalse(shortcut.eventModifiers.contains(.control))
+ }
+
+ func testNextPreviousWorkspaceShortcutDefaultsAndMetadata() {
+ XCTAssertEqual(KeyboardShortcutSettings.Action.nextSidebarTab.label, "Next Workspace")
+ XCTAssertEqual(KeyboardShortcutSettings.Action.prevSidebarTab.label, "Previous Workspace")
+ XCTAssertEqual(KeyboardShortcutSettings.Action.nextSidebarTab.defaultsKey, "shortcut.nextSidebarTab")
+ XCTAssertEqual(KeyboardShortcutSettings.Action.prevSidebarTab.defaultsKey, "shortcut.prevSidebarTab")
+
+ let nextShortcut = KeyboardShortcutSettings.Action.nextSidebarTab.defaultShortcut
+ XCTAssertEqual(nextShortcut.key, "]")
+ XCTAssertTrue(nextShortcut.command)
+ XCTAssertFalse(nextShortcut.shift)
+ XCTAssertFalse(nextShortcut.option)
+ XCTAssertTrue(nextShortcut.control)
+
+ let prevShortcut = KeyboardShortcutSettings.Action.prevSidebarTab.defaultShortcut
+ XCTAssertEqual(prevShortcut.key, "[")
+ XCTAssertTrue(prevShortcut.command)
+ XCTAssertFalse(prevShortcut.shift)
+ XCTAssertFalse(prevShortcut.option)
+ XCTAssertTrue(prevShortcut.control)
+ }
+
+ func testNextPreviousWorkspaceShortcutsConvertToMenuShortcut() {
+ let nextShortcut = KeyboardShortcutSettings.Action.nextSidebarTab.defaultShortcut
+ XCTAssertNotNil(nextShortcut.keyEquivalent)
+ XCTAssertEqual(nextShortcut.menuItemKeyEquivalent, "]")
+ XCTAssertTrue(nextShortcut.eventModifiers.contains(.command))
+ XCTAssertTrue(nextShortcut.eventModifiers.contains(.control))
+
+ let prevShortcut = KeyboardShortcutSettings.Action.prevSidebarTab.defaultShortcut
+ XCTAssertNotNil(prevShortcut.keyEquivalent)
+ XCTAssertEqual(prevShortcut.menuItemKeyEquivalent, "[")
+ XCTAssertTrue(prevShortcut.eventModifiers.contains(.command))
+ XCTAssertTrue(prevShortcut.eventModifiers.contains(.control))
+ }
+
+ func testToggleTerminalCopyModeShortcutDefaultsAndMetadata() {
+ XCTAssertEqual(KeyboardShortcutSettings.Action.toggleTerminalCopyMode.label, "Toggle Terminal Copy Mode")
+ XCTAssertEqual(
+ KeyboardShortcutSettings.Action.toggleTerminalCopyMode.defaultsKey,
+ "shortcut.toggleTerminalCopyMode"
+ )
+
+ let shortcut = KeyboardShortcutSettings.Action.toggleTerminalCopyMode.defaultShortcut
+ XCTAssertEqual(shortcut.key, "m")
+ XCTAssertTrue(shortcut.command)
+ XCTAssertTrue(shortcut.shift)
+ XCTAssertFalse(shortcut.option)
+ XCTAssertFalse(shortcut.control)
+ }
+
+ func testMenuItemKeyEquivalentHandlesArrowAndTabKeys() {
+ XCTAssertNotNil(StoredShortcut(key: "←", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent)
+ XCTAssertNotNil(StoredShortcut(key: "→", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent)
+ XCTAssertNotNil(StoredShortcut(key: "↑", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent)
+ XCTAssertNotNil(StoredShortcut(key: "↓", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent)
+ XCTAssertEqual(
+ StoredShortcut(key: "\t", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent,
+ "\t"
+ )
+ }
+
+ func testShortcutDefaultsKeysRemainUnique() {
+ let keys = KeyboardShortcutSettings.Action.allCases.map(\.defaultsKey)
+ XCTAssertEqual(Set(keys).count, keys.count)
+ }
+}
+
+
+final class WorkspaceShortcutMapperTests: XCTestCase {
+ func testCommandNineMapsToLastWorkspaceIndex() {
+ XCTAssertEqual(WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: 9, workspaceCount: 1), 0)
+ XCTAssertEqual(WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: 9, workspaceCount: 4), 3)
+ XCTAssertEqual(WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: 9, workspaceCount: 12), 11)
+ }
+
+ func testCommandDigitBadgesUseNineForLastWorkspaceWhenNeeded() {
+ XCTAssertEqual(WorkspaceShortcutMapper.commandDigitForWorkspace(at: 0, workspaceCount: 12), 1)
+ XCTAssertEqual(WorkspaceShortcutMapper.commandDigitForWorkspace(at: 7, workspaceCount: 12), 8)
+ XCTAssertEqual(WorkspaceShortcutMapper.commandDigitForWorkspace(at: 11, workspaceCount: 12), 9)
+ XCTAssertNil(WorkspaceShortcutMapper.commandDigitForWorkspace(at: 8, workspaceCount: 12))
+ }
+}
+
+
+final class WorkspacePlacementSettingsTests: XCTestCase {
+ func testCurrentPlacementDefaultsToAfterCurrentWhenUnset() {
+ let suiteName = "WorkspacePlacementSettingsTests.Default.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ XCTAssertEqual(WorkspacePlacementSettings.current(defaults: defaults), .afterCurrent)
+ }
+
+ func testCurrentPlacementReadsStoredValidValueAndFallsBackForInvalid() {
+ let suiteName = "WorkspacePlacementSettingsTests.Stored.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ defaults.set(NewWorkspacePlacement.top.rawValue, forKey: WorkspacePlacementSettings.placementKey)
+ XCTAssertEqual(WorkspacePlacementSettings.current(defaults: defaults), .top)
+
+ defaults.set("nope", forKey: WorkspacePlacementSettings.placementKey)
+ XCTAssertEqual(WorkspacePlacementSettings.current(defaults: defaults), .afterCurrent)
+ }
+
+ func testInsertionIndexTopInsertsBeforeUnpinned() {
+ let index = WorkspacePlacementSettings.insertionIndex(
+ placement: .top,
+ selectedIndex: 4,
+ selectedIsPinned: false,
+ pinnedCount: 2,
+ totalCount: 7
+ )
+ XCTAssertEqual(index, 2)
+ }
+
+ func testInsertionIndexAfterCurrentHandlesPinnedAndUnpinnedSelection() {
+ let afterUnpinned = WorkspacePlacementSettings.insertionIndex(
+ placement: .afterCurrent,
+ selectedIndex: 3,
+ selectedIsPinned: false,
+ pinnedCount: 2,
+ totalCount: 6
+ )
+ XCTAssertEqual(afterUnpinned, 4)
+
+ let afterPinned = WorkspacePlacementSettings.insertionIndex(
+ placement: .afterCurrent,
+ selectedIndex: 0,
+ selectedIsPinned: true,
+ pinnedCount: 2,
+ totalCount: 6
+ )
+ XCTAssertEqual(afterPinned, 2)
+ }
+
+ func testInsertionIndexEndAndNoSelectionAppend() {
+ let endIndex = WorkspacePlacementSettings.insertionIndex(
+ placement: .end,
+ selectedIndex: 1,
+ selectedIsPinned: false,
+ pinnedCount: 1,
+ totalCount: 5
+ )
+ XCTAssertEqual(endIndex, 5)
+
+ let noSelectionIndex = WorkspacePlacementSettings.insertionIndex(
+ placement: .afterCurrent,
+ selectedIndex: nil,
+ selectedIsPinned: false,
+ pinnedCount: 0,
+ totalCount: 5
+ )
+ XCTAssertEqual(noSelectionIndex, 5)
+ }
+}
+
+
+@MainActor
+final class WorkspaceCreationPlacementTests: XCTestCase {
+ func testAddWorkspaceDefaultPlacementMatchesCurrentSetting() {
+ let currentPlacement = WorkspacePlacementSettings.current()
+
+ let defaultManager = makeManagerWithThreeWorkspaces()
+ let defaultBaselineOrder = defaultManager.tabs.map(\.id)
+ let defaultInserted = defaultManager.addWorkspace()
+ guard let defaultInsertedIndex = defaultManager.tabs.firstIndex(where: { $0.id == defaultInserted.id }) else {
+ XCTFail("Expected inserted workspace in tab list")
+ return
+ }
+ XCTAssertEqual(defaultManager.tabs.map(\.id).filter { $0 != defaultInserted.id }, defaultBaselineOrder)
+
+ let explicitManager = makeManagerWithThreeWorkspaces()
+ let explicitBaselineOrder = explicitManager.tabs.map(\.id)
+ let explicitInserted = explicitManager.addWorkspace(placementOverride: currentPlacement)
+ guard let explicitInsertedIndex = explicitManager.tabs.firstIndex(where: { $0.id == explicitInserted.id }) else {
+ XCTFail("Expected inserted workspace in tab list")
+ return
+ }
+ XCTAssertEqual(explicitManager.tabs.map(\.id).filter { $0 != explicitInserted.id }, explicitBaselineOrder)
+ XCTAssertEqual(defaultInsertedIndex, explicitInsertedIndex)
+ }
+
+ func testAddWorkspaceEndOverrideAlwaysAppends() {
+ let manager = makeManagerWithThreeWorkspaces()
+ let baselineCount = manager.tabs.count
+ guard baselineCount >= 3 else {
+ XCTFail("Expected at least three workspaces for placement regression test")
+ return
+ }
+
+ let inserted = manager.addWorkspace(placementOverride: .end)
+ guard let insertedIndex = manager.tabs.firstIndex(where: { $0.id == inserted.id }) else {
+ XCTFail("Expected inserted workspace in tab list")
+ return
+ }
+
+ XCTAssertEqual(insertedIndex, baselineCount)
+ }
+
+ private func makeManagerWithThreeWorkspaces() -> TabManager {
+ let manager = TabManager()
+ _ = manager.addWorkspace()
+ _ = manager.addWorkspace()
+ if let first = manager.tabs.first {
+ manager.selectWorkspace(first)
+ }
+ return manager
+ }
+}
+
+
+final class WorkspaceTabColorSettingsTests: XCTestCase {
+ func testNormalizedHexAcceptsAndNormalizesValidInput() {
+ XCTAssertEqual(WorkspaceTabColorSettings.normalizedHex("#abc123"), "#ABC123")
+ XCTAssertEqual(WorkspaceTabColorSettings.normalizedHex(" aBcDeF "), "#ABCDEF")
+ XCTAssertNil(WorkspaceTabColorSettings.normalizedHex("#1234"))
+ XCTAssertNil(WorkspaceTabColorSettings.normalizedHex("#GG1234"))
+ }
+
+ func testBuiltInPaletteMatchesOriginalPRPalette() {
+ let suiteName = "WorkspaceTabColorSettingsTests.BuiltInPalette.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let palette = WorkspaceTabColorSettings.defaultPaletteWithOverrides(defaults: defaults)
+ XCTAssertEqual(palette.count, 16)
+ XCTAssertEqual(palette.first?.name, "Red")
+ XCTAssertEqual(palette.first?.hex, "#C0392B")
+ XCTAssertEqual(palette.last?.name, "Charcoal")
+ XCTAssertFalse(palette.contains(where: { $0.name == "Gold" }))
+ }
+
+ func testDefaultOverrideRoundTripFallsBackWhenResetToBase() {
+ let suiteName = "WorkspaceTabColorSettingsTests.DefaultOverride.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let first = WorkspaceTabColorSettings.defaultPalette[0]
+ XCTAssertEqual(
+ WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults),
+ first.hex
+ )
+
+ WorkspaceTabColorSettings.setDefaultColor(named: first.name, hex: "#00aa33", defaults: defaults)
+ XCTAssertEqual(
+ WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults),
+ "#00AA33"
+ )
+
+ WorkspaceTabColorSettings.setDefaultColor(named: first.name, hex: first.hex, defaults: defaults)
+ XCTAssertEqual(
+ WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults),
+ first.hex
+ )
+ }
+
+ func testAddCustomColorPersistsAndDeduplicatesByMostRecent() {
+ let suiteName = "WorkspaceTabColorSettingsTests.CustomColors.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ XCTAssertEqual(
+ WorkspaceTabColorSettings.addCustomColor(" #00aa33 ", defaults: defaults),
+ "#00AA33"
+ )
+ XCTAssertEqual(
+ WorkspaceTabColorSettings.addCustomColor("#112233", defaults: defaults),
+ "#112233"
+ )
+ XCTAssertEqual(
+ WorkspaceTabColorSettings.addCustomColor("#00AA33", defaults: defaults),
+ "#00AA33"
+ )
+ XCTAssertNil(WorkspaceTabColorSettings.addCustomColor("nope", defaults: defaults))
+
+ XCTAssertEqual(
+ WorkspaceTabColorSettings.customColors(defaults: defaults),
+ ["#00AA33", "#112233"]
+ )
+ }
+
+ func testPaletteIncludesCustomEntriesAndResetClearsAll() {
+ let suiteName = "WorkspaceTabColorSettingsTests.Reset.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let first = WorkspaceTabColorSettings.defaultPalette[0]
+ WorkspaceTabColorSettings.setDefaultColor(named: first.name, hex: "#334455", defaults: defaults)
+ _ = WorkspaceTabColorSettings.addCustomColor("#778899", defaults: defaults)
+
+ let paletteBeforeReset = WorkspaceTabColorSettings.palette(defaults: defaults)
+ XCTAssertEqual(paletteBeforeReset.count, WorkspaceTabColorSettings.defaultPalette.count + 1)
+ XCTAssertEqual(paletteBeforeReset[0].hex, "#334455")
+ XCTAssertEqual(paletteBeforeReset.last?.name, "Custom 1")
+ XCTAssertEqual(paletteBeforeReset.last?.hex, "#778899")
+
+ WorkspaceTabColorSettings.reset(defaults: defaults)
+
+ XCTAssertEqual(WorkspaceTabColorSettings.customColors(defaults: defaults), [])
+ XCTAssertEqual(
+ WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults),
+ first.hex
+ )
+ }
+
+ func testDisplayColorLightModeKeepsOriginalHex() {
+ let originalHex = "#1A5276"
+ let rendered = WorkspaceTabColorSettings.displayNSColor(
+ hex: originalHex,
+ colorScheme: .light
+ )
+
+ XCTAssertEqual(rendered?.hexString(), originalHex)
+ }
+
+ func testDisplayColorDarkModeBrightensColor() {
+ let originalHex = "#1A5276"
+ guard let base = NSColor(hex: originalHex),
+ let rendered = WorkspaceTabColorSettings.displayNSColor(
+ hex: originalHex,
+ colorScheme: .dark
+ ) else {
+ XCTFail("Expected valid color conversion")
+ return
+ }
+
+ XCTAssertNotEqual(rendered.hexString(), originalHex)
+ XCTAssertGreaterThan(rendered.luminance, base.luminance)
+ }
+
+ func testDisplayColorDarkModeKeepsGrayscaleNeutral() {
+ let originalHex = "#808080"
+ guard let base = NSColor(hex: originalHex),
+ let rendered = WorkspaceTabColorSettings.displayNSColor(
+ hex: originalHex,
+ colorScheme: .dark
+ ),
+ let renderedSRGB = rendered.usingColorSpace(.sRGB) else {
+ XCTFail("Expected valid color conversion")
+ return
+ }
+
+ XCTAssertGreaterThan(rendered.luminance, base.luminance)
+ XCTAssertLessThan(abs(renderedSRGB.redComponent - renderedSRGB.greenComponent), 0.003)
+ XCTAssertLessThan(abs(renderedSRGB.greenComponent - renderedSRGB.blueComponent), 0.003)
+ }
+
+ func testDisplayColorForceBrightensInLightMode() {
+ let originalHex = "#1A5276"
+ guard let base = NSColor(hex: originalHex),
+ let rendered = WorkspaceTabColorSettings.displayNSColor(
+ hex: originalHex,
+ colorScheme: .light,
+ forceBright: true
+ ) else {
+ XCTFail("Expected valid color conversion")
+ return
+ }
+
+ XCTAssertNotEqual(rendered.hexString(), originalHex)
+ XCTAssertGreaterThan(rendered.luminance, base.luminance)
+ }
+}
+
+
+final class WorkspaceAutoReorderSettingsTests: XCTestCase {
+ func testDefaultIsEnabled() {
+ let suiteName = "WorkspaceAutoReorderSettingsTests.Default.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ XCTAssertTrue(WorkspaceAutoReorderSettings.isEnabled(defaults: defaults))
+ }
+
+ func testDisabledWhenSetToFalse() {
+ let suiteName = "WorkspaceAutoReorderSettingsTests.Disabled.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ defaults.set(false, forKey: WorkspaceAutoReorderSettings.key)
+ XCTAssertFalse(WorkspaceAutoReorderSettings.isEnabled(defaults: defaults))
+ }
+
+ func testEnabledWhenSetToTrue() {
+ let suiteName = "WorkspaceAutoReorderSettingsTests.Enabled.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ defaults.set(true, forKey: WorkspaceAutoReorderSettings.key)
+ XCTAssertTrue(WorkspaceAutoReorderSettings.isEnabled(defaults: defaults))
+ }
+}
+
+
+final class SidebarWorkspaceDetailSettingsTests: XCTestCase {
+ func testDefaultPreferencesWhenUnset() {
+ let suiteName = "SidebarWorkspaceDetailSettingsTests.Default.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ XCTAssertFalse(SidebarWorkspaceDetailSettings.hidesAllDetails(defaults: defaults))
+ XCTAssertTrue(SidebarWorkspaceDetailSettings.showsNotificationMessage(defaults: defaults))
+ XCTAssertTrue(
+ SidebarWorkspaceDetailSettings.resolvedNotificationMessageVisibility(
+ showNotificationMessage: SidebarWorkspaceDetailSettings.showsNotificationMessage(defaults: defaults),
+ hideAllDetails: SidebarWorkspaceDetailSettings.hidesAllDetails(defaults: defaults)
+ )
+ )
+ }
+
+ func testStoredPreferencesOverrideDefaults() {
+ let suiteName = "SidebarWorkspaceDetailSettingsTests.Stored.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ defaults.set(true, forKey: SidebarWorkspaceDetailSettings.hideAllDetailsKey)
+ defaults.set(false, forKey: SidebarWorkspaceDetailSettings.showNotificationMessageKey)
+
+ XCTAssertTrue(SidebarWorkspaceDetailSettings.hidesAllDetails(defaults: defaults))
+ XCTAssertFalse(SidebarWorkspaceDetailSettings.showsNotificationMessage(defaults: defaults))
+ XCTAssertFalse(
+ SidebarWorkspaceDetailSettings.resolvedNotificationMessageVisibility(
+ showNotificationMessage: SidebarWorkspaceDetailSettings.showsNotificationMessage(defaults: defaults),
+ hideAllDetails: false
+ )
+ )
+ XCTAssertFalse(
+ SidebarWorkspaceDetailSettings.resolvedNotificationMessageVisibility(
+ showNotificationMessage: true,
+ hideAllDetails: SidebarWorkspaceDetailSettings.hidesAllDetails(defaults: defaults)
+ )
+ )
+ }
+}
+
+
+final class SidebarWorkspaceAuxiliaryDetailVisibilityTests: XCTestCase {
+ func testResolvedVisibilityPreservesPerRowTogglesWhenDetailsAreShown() {
+ XCTAssertEqual(
+ SidebarWorkspaceAuxiliaryDetailVisibility.resolved(
+ showMetadata: true,
+ showLog: false,
+ showProgress: true,
+ showBranchDirectory: false,
+ showPullRequests: true,
+ showPorts: false,
+ hideAllDetails: false
+ ),
+ SidebarWorkspaceAuxiliaryDetailVisibility(
+ showsMetadata: true,
+ showsLog: false,
+ showsProgress: true,
+ showsBranchDirectory: false,
+ showsPullRequests: true,
+ showsPorts: false
+ )
+ )
+ }
+
+ func testResolvedVisibilityHidesAllAuxiliaryRowsWhenDetailsAreHidden() {
+ XCTAssertEqual(
+ SidebarWorkspaceAuxiliaryDetailVisibility.resolved(
+ showMetadata: true,
+ showLog: true,
+ showProgress: true,
+ showBranchDirectory: true,
+ showPullRequests: true,
+ showPorts: true,
+ hideAllDetails: true
+ ),
+ .hidden
+ )
+ }
+}
+
+
+final class WorkspaceReorderTests: XCTestCase {
+ @MainActor
+ func testReorderWorkspaceMovesWorkspaceToRequestedIndex() {
+ let manager = TabManager()
+ let first = manager.tabs[0]
+ let second = manager.addWorkspace()
+ let third = manager.addWorkspace()
+
+ manager.selectWorkspace(second)
+ XCTAssertEqual(manager.selectedTabId, second.id)
+
+ XCTAssertTrue(manager.reorderWorkspace(tabId: second.id, toIndex: 0))
+ XCTAssertEqual(manager.tabs.map(\.id), [second.id, first.id, third.id])
+ XCTAssertEqual(manager.selectedTabId, second.id)
+ }
+
+ @MainActor
+ func testReorderWorkspaceClampsOutOfRangeTargetIndex() {
+ let manager = TabManager()
+ let first = manager.tabs[0]
+ let second = manager.addWorkspace()
+ let third = manager.addWorkspace()
+
+ XCTAssertTrue(manager.reorderWorkspace(tabId: first.id, toIndex: 999))
+ XCTAssertEqual(manager.tabs.map(\.id), [second.id, third.id, first.id])
+ }
+
+ @MainActor
+ func testReorderWorkspaceReturnsFalseForUnknownWorkspace() {
+ let manager = TabManager()
+ XCTAssertFalse(manager.reorderWorkspace(tabId: UUID(), toIndex: 0))
+ }
+
+ @MainActor
+ func testReorderWorkspaceKeepsUnpinnedWorkspaceBelowPinnedSegment() {
+ let manager = TabManager()
+ let firstPinned = manager.tabs[0]
+ manager.setPinned(firstPinned, pinned: true)
+ let secondPinned = manager.addWorkspace()
+ manager.setPinned(secondPinned, pinned: true)
+ let unpinned = manager.addWorkspace()
+
+ XCTAssertTrue(manager.reorderWorkspace(tabId: unpinned.id, toIndex: 0))
+ XCTAssertEqual(manager.tabs.map(\.id), [firstPinned.id, secondPinned.id, unpinned.id])
+ }
+
+ @MainActor
+ func testReorderWorkspaceKeepsPinnedWorkspaceInsidePinnedSegment() {
+ let manager = TabManager()
+ let firstPinned = manager.tabs[0]
+ manager.setPinned(firstPinned, pinned: true)
+ let secondPinned = manager.addWorkspace()
+ manager.setPinned(secondPinned, pinned: true)
+ let unpinned = manager.addWorkspace()
+
+ XCTAssertTrue(manager.reorderWorkspace(tabId: firstPinned.id, toIndex: 999))
+ XCTAssertEqual(manager.tabs.map(\.id), [secondPinned.id, firstPinned.id, unpinned.id])
+ }
+}
+
+
+@MainActor
+final class WorkspaceNotificationReorderTests: XCTestCase {
+ func testNotificationAutoReorderDoesNotMovePinnedWorkspace() {
+ let appDelegate = AppDelegate.shared ?? AppDelegate()
+ let manager = TabManager()
+ let notificationStore = TerminalNotificationStore.shared
+
+ let originalTabManager = appDelegate.tabManager
+ let originalNotificationStore = appDelegate.notificationStore
+ let defaults = UserDefaults.standard
+ let originalAutoReorderSetting = defaults.object(forKey: WorkspaceAutoReorderSettings.key)
+ let originalAppFocusOverride = AppFocusState.overrideIsFocused
+
+ notificationStore.replaceNotificationsForTesting([])
+ notificationStore.configureNotificationDeliveryHandlerForTesting { _, _ in }
+ appDelegate.tabManager = manager
+ appDelegate.notificationStore = notificationStore
+ defaults.set(true, forKey: WorkspaceAutoReorderSettings.key)
+ AppFocusState.overrideIsFocused = false
+
+ defer {
+ notificationStore.replaceNotificationsForTesting([])
+ notificationStore.resetNotificationDeliveryHandlerForTesting()
+ appDelegate.tabManager = originalTabManager
+ appDelegate.notificationStore = originalNotificationStore
+ AppFocusState.overrideIsFocused = originalAppFocusOverride
+ if let originalAutoReorderSetting {
+ defaults.set(originalAutoReorderSetting, forKey: WorkspaceAutoReorderSettings.key)
+ } else {
+ defaults.removeObject(forKey: WorkspaceAutoReorderSettings.key)
+ }
+ }
+
+ let firstPinned = manager.tabs[0]
+ manager.setPinned(firstPinned, pinned: true)
+ let secondPinned = manager.addWorkspace()
+ manager.setPinned(secondPinned, pinned: true)
+ let unpinned = manager.addWorkspace()
+ let expectedOrder = [firstPinned.id, secondPinned.id, unpinned.id]
+
+ notificationStore.addNotification(
+ tabId: secondPinned.id,
+ surfaceId: nil,
+ title: "Build finished",
+ subtitle: "",
+ body: "Pinned workspaces should stay put"
+ )
+
+ XCTAssertEqual(manager.tabs.map(\.id), expectedOrder)
+ }
+}
+
+
+@MainActor
+final class WorkspaceTeardownTests: XCTestCase {
+ func testTeardownAllPanelsClearsPanelMetadataCaches() {
+ let workspace = Workspace()
+ guard let initialPanelId = workspace.focusedPanelId else {
+ XCTFail("Expected focused panel in new workspace")
+ return
+ }
+
+ workspace.setPanelCustomTitle(panelId: initialPanelId, title: "Initial custom title")
+ workspace.setPanelPinned(panelId: initialPanelId, pinned: true)
+
+ guard let splitPanel = workspace.newTerminalSplit(from: initialPanelId, orientation: .horizontal) else {
+ XCTFail("Expected split panel to be created")
+ return
+ }
+
+ workspace.setPanelCustomTitle(panelId: splitPanel.id, title: "Split custom title")
+ workspace.setPanelPinned(panelId: splitPanel.id, pinned: true)
+ workspace.markPanelUnread(initialPanelId)
+
+ XCTAssertFalse(workspace.panels.isEmpty)
+ XCTAssertFalse(workspace.panelTitles.isEmpty)
+ XCTAssertFalse(workspace.panelCustomTitles.isEmpty)
+ XCTAssertFalse(workspace.pinnedPanelIds.isEmpty)
+ XCTAssertFalse(workspace.manualUnreadPanelIds.isEmpty)
+
+ workspace.teardownAllPanels()
+
+ XCTAssertTrue(workspace.panels.isEmpty)
+ XCTAssertTrue(workspace.panelTitles.isEmpty)
+ XCTAssertTrue(workspace.panelCustomTitles.isEmpty)
+ XCTAssertTrue(workspace.pinnedPanelIds.isEmpty)
+ XCTAssertTrue(workspace.manualUnreadPanelIds.isEmpty)
+ }
+}
+
+
+@MainActor
+final class WorkspaceSplitWorkingDirectoryTests: XCTestCase {
+ func testNewTerminalSplitFallsBackToRequestedWorkingDirectoryWhenReportedDirectoryIsStale() {
+ let workspace = Workspace()
+ guard let sourcePaneId = workspace.bonsplitController.focusedPaneId else {
+ XCTFail("Expected focused pane in new workspace")
+ return
+ }
+
+ let staleCurrentDirectory = workspace.currentDirectory
+ let requestedDirectory = "/tmp/cmux-requested-split-cwd-\(UUID().uuidString)"
+ guard let sourcePanel = workspace.newTerminalSurface(
+ inPane: sourcePaneId,
+ focus: false,
+ workingDirectory: requestedDirectory
+ ) else {
+ XCTFail("Expected source terminal panel to be created")
+ return
+ }
+
+ XCTAssertEqual(sourcePanel.requestedWorkingDirectory, requestedDirectory)
+ XCTAssertNil(
+ workspace.panelDirectories[sourcePanel.id],
+ "Expected requested cwd to exist before shell integration reports a live cwd"
+ )
+ XCTAssertEqual(
+ workspace.currentDirectory,
+ staleCurrentDirectory,
+ "Expected focused workspace cwd to remain stale before panel directory updates"
+ )
+
+ guard let splitPanel = workspace.newTerminalSplit(
+ from: sourcePanel.id,
+ orientation: .horizontal,
+ focus: false
+ ) else {
+ XCTFail("Expected split terminal panel to be created")
+ return
+ }
+
+ XCTAssertEqual(
+ splitPanel.requestedWorkingDirectory,
+ requestedDirectory,
+ "Expected split to inherit the source terminal's requested cwd when no reported cwd exists yet"
+ )
+ }
+}
+
+
+@MainActor
+final class WorkspaceTerminalFocusRecoveryTests: XCTestCase {
+ private func makeWindow() -> NSWindow {
+ NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 360, height: 220),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ }
+
+ private func makeMouseEvent(
+ type: NSEvent.EventType,
+ location: NSPoint,
+ window: NSWindow
+ ) -> NSEvent {
+ guard let event = NSEvent.mouseEvent(
+ with: type,
+ location: location,
+ modifierFlags: [],
+ timestamp: ProcessInfo.processInfo.systemUptime,
+ windowNumber: window.windowNumber,
+ context: nil,
+ eventNumber: 0,
+ clickCount: 1,
+ pressure: 1.0
+ ) else {
+ fatalError("Failed to create \(type) mouse event")
+ }
+ return event
+ }
+
+ private func surfaceView(in hostedView: GhosttySurfaceScrollView) -> GhosttyNSView? {
+ var stack: [NSView] = [hostedView]
+ while let current = stack.popLast() {
+ if let surfaceView = current as? GhosttyNSView {
+ return surfaceView
+ }
+ stack.append(contentsOf: current.subviews)
+ }
+ return nil
+ }
+
+ func testTerminalFirstResponderConvergesSplitActiveStateWhenSelectionAlreadyMatches() {
+ let workspace = Workspace()
+ guard let leftPanelId = workspace.focusedPanelId,
+ let leftPanel = workspace.terminalPanel(for: leftPanelId),
+ let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
+ XCTFail("Expected split terminal panels")
+ return
+ }
+
+ XCTAssertEqual(
+ workspace.focusedPanelId,
+ rightPanel.id,
+ "Expected the new split panel to be selected before simulating stale focus state"
+ )
+
+ // Simulate the split-pane failure mode: Bonsplit already points at the right panel,
+ // but the active terminal state is still stale on the left panel.
+ leftPanel.surface.setFocus(true)
+ leftPanel.hostedView.setActive(true)
+ rightPanel.surface.setFocus(false)
+ rightPanel.hostedView.setActive(false)
+
+ workspace.focusPanel(rightPanel.id, trigger: .terminalFirstResponder)
+
+ XCTAssertFalse(
+ leftPanel.hostedView.debugRenderStats().isActive,
+ "Expected stale left-pane active state to be cleared"
+ )
+ XCTAssertTrue(
+ rightPanel.hostedView.debugRenderStats().isActive,
+ "Expected terminal-first-responder recovery to reactivate the selected split pane"
+ )
+ }
+
+ func testTerminalClickRecoversSplitActiveStateWhenFocusCallbackIsSuppressed() {
+ let workspace = Workspace()
+ guard let leftPanelId = workspace.focusedPanelId,
+ let leftPanel = workspace.terminalPanel(for: leftPanelId),
+ let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
+ XCTFail("Expected split terminal panels")
+ return
+ }
+
+ let window = makeWindow()
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ leftPanel.hostedView.frame = NSRect(x: 0, y: 0, width: 180, height: 220)
+ rightPanel.hostedView.frame = NSRect(x: 180, y: 0, width: 180, height: 220)
+ contentView.addSubview(leftPanel.hostedView)
+ contentView.addSubview(rightPanel.hostedView)
+
+ leftPanel.hostedView.setVisibleInUI(true)
+ rightPanel.hostedView.setVisibleInUI(true)
+ leftPanel.hostedView.setFocusHandler {
+ workspace.focusPanel(leftPanel.id, trigger: .terminalFirstResponder)
+ }
+ rightPanel.hostedView.setFocusHandler {
+ workspace.focusPanel(rightPanel.id, trigger: .terminalFirstResponder)
+ }
+
+ window.makeKeyAndOrderFront(nil)
+ window.displayIfNeeded()
+ contentView.layoutSubtreeIfNeeded()
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+
+ XCTAssertEqual(
+ workspace.focusedPanelId,
+ rightPanel.id,
+ "Expected the clicked split pane to already be selected before simulating stale focus state"
+ )
+
+ // Simulate the ghost-terminal race: the right pane is selected in Bonsplit, but stale
+ // active state remains on the left and the right pane's AppKit focus callback never fires
+ // after split reparent/layout churn.
+ leftPanel.surface.setFocus(true)
+ leftPanel.hostedView.setActive(true)
+ rightPanel.surface.setFocus(false)
+ rightPanel.hostedView.setActive(false)
+ rightPanel.hostedView.suppressReparentFocus()
+
+ guard let rightSurfaceView = surfaceView(in: rightPanel.hostedView) else {
+ XCTFail("Expected right terminal surface view")
+ return
+ }
+
+ let pointInWindow = rightSurfaceView.convert(NSPoint(x: 24, y: 24), to: nil)
+ let event = makeMouseEvent(type: .leftMouseDown, location: pointInWindow, window: window)
+ rightSurfaceView.mouseDown(with: event)
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+
+ XCTAssertFalse(
+ leftPanel.hostedView.debugRenderStats().isActive,
+ "Expected clicking the selected split pane to clear stale sibling active state even when AppKit focus callbacks are suppressed"
+ )
+ XCTAssertTrue(
+ rightPanel.hostedView.debugRenderStats().isActive,
+ "Expected clicking the selected split pane to reactivate terminal input when focus callbacks are suppressed"
+ )
+ XCTAssertTrue(
+ rightPanel.hostedView.isSurfaceViewFirstResponder(),
+ "Expected the clicked split pane to become first responder"
+ )
+ }
+}
+
+
+@MainActor
+final class WorkspaceTerminalConfigInheritanceSelectionTests: XCTestCase {
+ func testPrefersSelectedTerminalInTargetPaneOverFocusedTerminalElsewhere() {
+ let manager = TabManager()
+ guard let workspace = manager.selectedWorkspace,
+ let leftPanelId = workspace.focusedPanelId,
+ let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal),
+ let leftPaneId = workspace.paneId(forPanelId: leftPanelId) else {
+ XCTFail("Expected workspace split setup to succeed")
+ return
+ }
+
+ // Programmatic split focuses the new right panel by default.
+ XCTAssertEqual(workspace.focusedPanelId, rightPanel.id)
+
+ let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: leftPaneId)
+ XCTAssertEqual(
+ sourcePanel?.id,
+ leftPanelId,
+ "Expected inheritance to use the selected terminal in the target pane"
+ )
+ }
+
+ func testFallsBackToAnotherTerminalInPaneWhenSelectedTabIsBrowser() {
+ let manager = TabManager()
+ guard let workspace = manager.selectedWorkspace,
+ let terminalPanelId = workspace.focusedPanelId,
+ let paneId = workspace.paneId(forPanelId: terminalPanelId),
+ let browserPanel = workspace.newBrowserSurface(inPane: paneId, focus: true) else {
+ XCTFail("Expected workspace browser setup to succeed")
+ return
+ }
+
+ XCTAssertEqual(workspace.focusedPanelId, browserPanel.id)
+
+ let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: paneId)
+ XCTAssertEqual(
+ sourcePanel?.id,
+ terminalPanelId,
+ "Expected inheritance to fall back to a terminal in the pane when browser is selected"
+ )
+ }
+
+ func testPreferredTerminalPanelWinsWhenProvided() {
+ let manager = TabManager()
+ guard let workspace = manager.selectedWorkspace,
+ let terminalPanelId = workspace.focusedPanelId else {
+ XCTFail("Expected selected workspace with a terminal panel")
+ return
+ }
+
+ let sourcePanel = workspace.terminalPanelForConfigInheritance(preferredPanelId: terminalPanelId)
+ XCTAssertEqual(sourcePanel?.id, terminalPanelId)
+ }
+
+ func testPrefersLastFocusedTerminalWhenBrowserFocusedInDifferentPane() {
+ let manager = TabManager()
+ guard let workspace = manager.selectedWorkspace,
+ let leftTerminalPanelId = workspace.focusedPanelId,
+ let rightTerminalPanel = workspace.newTerminalSplit(from: leftTerminalPanelId, orientation: .horizontal),
+ let rightPaneId = workspace.paneId(forPanelId: rightTerminalPanel.id) else {
+ XCTFail("Expected split setup to succeed")
+ return
+ }
+
+ workspace.focusPanel(leftTerminalPanelId)
+ _ = workspace.newBrowserSurface(inPane: rightPaneId, focus: true)
+ XCTAssertNotEqual(workspace.focusedPanelId, leftTerminalPanelId)
+
+ let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: rightPaneId)
+ XCTAssertEqual(
+ sourcePanel?.id,
+ leftTerminalPanelId,
+ "Expected inheritance to prefer last focused terminal when browser is focused in another pane"
+ )
+ }
+}
+
+
+@MainActor
+final class WorkspaceBrowserProfileSelectionTests: XCTestCase {
+ private final class RejectingCreateTabDelegate: BonsplitDelegate {
+ func splitTabBar(_ controller: BonsplitController, shouldCreateTab tab: Bonsplit.Tab, inPane pane: PaneID) -> Bool {
+ false
+ }
+ }
+
+ private final class RejectingSplitPaneDelegate: BonsplitDelegate {
+ func splitTabBar(_ controller: BonsplitController, shouldSplitPane pane: PaneID, orientation: SplitOrientation) -> Bool {
+ false
+ }
+ }
+
+ func testNewBrowserSurfacePrefersSelectedBrowserProfileInTargetPane() throws {
+ let workspace = Workspace()
+ let profileA = try makeTemporaryBrowserProfile(named: "Alpha")
+ let profileB = try makeTemporaryBrowserProfile(named: "Beta")
+ let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId)
+ let browserA = try XCTUnwrap(
+ workspace.newBrowserSurface(
+ inPane: paneId,
+ focus: true,
+ preferredProfileID: profileA.id
+ )
+ )
+ _ = try XCTUnwrap(
+ workspace.newBrowserSplit(
+ from: browserA.id,
+ orientation: .horizontal,
+ preferredProfileID: profileB.id,
+ focus: true
+ )
+ )
+
+ XCTAssertEqual(
+ workspace.preferredBrowserProfileID,
+ profileB.id,
+ "Expected workspace preference to drift to the most recently created browser profile"
+ )
+
+ let leftSurfaceId = try XCTUnwrap(workspace.surfaceIdFromPanelId(browserA.id))
+ workspace.bonsplitController.focusPane(paneId)
+ workspace.bonsplitController.selectTab(leftSurfaceId)
+
+ let created = try XCTUnwrap(
+ workspace.newBrowserSurface(
+ inPane: paneId,
+ focus: false
+ )
+ )
+
+ XCTAssertEqual(
+ created.profileID,
+ profileA.id,
+ "Expected new browser creation to inherit the selected browser profile from the target pane"
+ )
+ }
+
+ func testNewBrowserSurfaceFailureDoesNotMutatePreferredProfile() throws {
+ let workspace = Workspace()
+ let preferredProfile = try makeTemporaryBrowserProfile(named: "Preferred")
+ let unexpectedProfile = try makeTemporaryBrowserProfile(named: "Unexpected")
+
+ let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId)
+ _ = try XCTUnwrap(
+ workspace.newBrowserSurface(
+ inPane: paneId,
+ focus: false,
+ preferredProfileID: preferredProfile.id
+ )
+ )
+ XCTAssertEqual(workspace.preferredBrowserProfileID, preferredProfile.id)
+
+ let rejectingDelegate = RejectingCreateTabDelegate()
+ workspace.bonsplitController.delegate = rejectingDelegate
+ let created = workspace.newBrowserSurface(
+ inPane: paneId,
+ focus: false,
+ preferredProfileID: unexpectedProfile.id
+ )
+
+ XCTAssertNil(created)
+ XCTAssertEqual(
+ workspace.preferredBrowserProfileID,
+ preferredProfile.id,
+ "Expected a failed browser creation to leave the workspace preferred profile unchanged"
+ )
+ }
+
+ func testNewBrowserSplitFailureDoesNotMutatePreferredProfile() throws {
+ let workspace = Workspace()
+ let preferredProfile = try makeTemporaryBrowserProfile(named: "Preferred")
+ let unexpectedProfile = try makeTemporaryBrowserProfile(named: "Unexpected")
+
+ let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId)
+ let browser = try XCTUnwrap(
+ workspace.newBrowserSurface(
+ inPane: paneId,
+ focus: true,
+ preferredProfileID: preferredProfile.id
+ )
+ )
+ XCTAssertEqual(workspace.preferredBrowserProfileID, preferredProfile.id)
+
+ let rejectingDelegate = RejectingSplitPaneDelegate()
+ workspace.bonsplitController.delegate = rejectingDelegate
+ let created = workspace.newBrowserSplit(
+ from: browser.id,
+ orientation: .horizontal,
+ preferredProfileID: unexpectedProfile.id,
+ focus: false
+ )
+
+ XCTAssertNil(created)
+ XCTAssertEqual(
+ workspace.preferredBrowserProfileID,
+ preferredProfile.id,
+ "Expected a failed browser split to leave the workspace preferred profile unchanged"
+ )
+ }
+}
+
+
+@MainActor
+final class WorkspacePanelGitBranchTests: XCTestCase {
+ private func drainMainQueue() {
+ let expectation = expectation(description: "drain main queue")
+ DispatchQueue.main.async {
+ expectation.fulfill()
+ }
+ wait(for: [expectation], timeout: 1.0)
+ }
+
+ func testBrowserSplitWithFocusFalsePreservesOriginalFocusedPanel() {
+ let workspace = Workspace()
+ guard let originalFocusedPanelId = workspace.focusedPanelId else {
+ XCTFail("Expected initial focused panel")
+ return
+ }
+
+ guard let browserSplitPanel = workspace.newBrowserSplit(
+ from: originalFocusedPanelId,
+ orientation: .horizontal,
+ focus: false
+ ) else {
+ XCTFail("Expected browser split panel to be created")
+ return
+ }
+
+ drainMainQueue()
+
+ XCTAssertNotEqual(browserSplitPanel.id, originalFocusedPanelId)
+ XCTAssertEqual(
+ workspace.focusedPanelId,
+ originalFocusedPanelId,
+ "Expected non-focus browser split to preserve pre-split focus"
+ )
+ }
+
+ func testTerminalSplitWithFocusFalsePreservesOriginalFocusedPanel() {
+ let workspace = Workspace()
+ guard let originalFocusedPanelId = workspace.focusedPanelId else {
+ XCTFail("Expected initial focused panel")
+ return
+ }
+
+ guard let terminalSplitPanel = workspace.newTerminalSplit(
+ from: originalFocusedPanelId,
+ orientation: .horizontal,
+ focus: false
+ ) else {
+ XCTFail("Expected terminal split panel to be created")
+ return
+ }
+
+ drainMainQueue()
+
+ XCTAssertNotEqual(terminalSplitPanel.id, originalFocusedPanelId)
+ XCTAssertEqual(
+ workspace.focusedPanelId,
+ originalFocusedPanelId,
+ "Expected non-focus terminal split to preserve pre-split focus"
+ )
+ }
+
+ func testDetachLastSurfaceLeavesWorkspaceTemporarilyEmptyForMoveFlow() {
+ let workspace = Workspace()
+ guard let panelId = workspace.focusedPanelId,
+ let paneId = workspace.paneId(forPanelId: panelId) else {
+ XCTFail("Expected initial panel and pane")
+ return
+ }
+
+ XCTAssertEqual(workspace.panels.count, 1)
+#if DEBUG
+ let baselineFocusReconcileDuringDetach = workspace.debugFocusReconcileScheduledDuringDetachCount
+#endif
+
+ guard let detached = workspace.detachSurface(panelId: panelId) else {
+ XCTFail("Expected detach of last surface to succeed")
+ return
+ }
+
+ XCTAssertEqual(detached.panelId, panelId)
+ XCTAssertTrue(
+ workspace.panels.isEmpty,
+ "Detaching the last surface should not auto-create a replacement panel"
+ )
+ XCTAssertNil(workspace.surfaceIdFromPanelId(panelId))
+ XCTAssertEqual(workspace.bonsplitController.tabs(inPane: paneId).count, 0)
+
+ drainMainQueue()
+ drainMainQueue()
+#if DEBUG
+ XCTAssertEqual(
+ workspace.debugFocusReconcileScheduledDuringDetachCount,
+ baselineFocusReconcileDuringDetach,
+ "Detaching during cross-workspace moves should not schedule delayed source focus reconciliation"
+ )
+#endif
+
+ let restoredPanelId = workspace.attachDetachedSurface(detached, inPane: paneId, focus: false)
+ XCTAssertEqual(restoredPanelId, panelId)
+ XCTAssertEqual(workspace.panels.count, 1)
+ }
+
+ func testDetachSurfaceWithRemainingPanelsSkipsDelayedFocusReconcile() {
+ let workspace = Workspace()
+ guard let originalPanelId = workspace.focusedPanelId,
+ let movedPanel = workspace.newTerminalSplit(from: originalPanelId, orientation: .horizontal) else {
+ XCTFail("Expected two panels before detach")
+ return
+ }
+
+ drainMainQueue()
+ drainMainQueue()
+#if DEBUG
+ let baselineFocusReconcileDuringDetach = workspace.debugFocusReconcileScheduledDuringDetachCount
+#endif
+
+ guard let detached = workspace.detachSurface(panelId: movedPanel.id) else {
+ XCTFail("Expected detach to succeed")
+ return
+ }
+
+ XCTAssertEqual(detached.panelId, movedPanel.id)
+ XCTAssertEqual(workspace.panels.count, 1, "Expected source workspace to retain only the surviving panel")
+ XCTAssertNotNil(workspace.panels[originalPanelId], "Expected the original panel to remain after detach")
+
+ drainMainQueue()
+ drainMainQueue()
+#if DEBUG
+ XCTAssertEqual(
+ workspace.debugFocusReconcileScheduledDuringDetachCount,
+ baselineFocusReconcileDuringDetach,
+ "Detaching into another workspace should not enqueue delayed source focus reconciliation"
+ )
+#endif
+ }
+
+ func testDetachAttachAcrossWorkspacesPreservesNonCustomPanelTitle() {
+ let source = Workspace()
+ guard let panelId = source.focusedPanelId else {
+ XCTFail("Expected source focused panel")
+ return
+ }
+
+ XCTAssertTrue(source.updatePanelTitle(panelId: panelId, title: "detached-runtime-title"))
+
+ guard let detached = source.detachSurface(panelId: panelId) else {
+ XCTFail("Expected detach to succeed")
+ return
+ }
+
+ XCTAssertEqual(detached.cachedTitle, "detached-runtime-title")
+ XCTAssertNil(detached.customTitle)
+ XCTAssertEqual(
+ detached.title,
+ "detached-runtime-title",
+ "Detached transfer should carry the cached non-custom title"
+ )
+
+ let destination = Workspace()
+ guard let destinationPane = destination.bonsplitController.allPaneIds.first else {
+ XCTFail("Expected destination pane")
+ return
+ }
+
+ let attachedPanelId = destination.attachDetachedSurface(
+ detached,
+ inPane: destinationPane,
+ focus: false
+ )
+ XCTAssertEqual(attachedPanelId, panelId)
+ XCTAssertEqual(destination.panelTitle(panelId: panelId), "detached-runtime-title")
+
+ guard let attachedTabId = destination.surfaceIdFromPanelId(panelId),
+ let attachedTab = destination.bonsplitController.tab(attachedTabId) else {
+ XCTFail("Expected attached tab mapping")
+ return
+ }
+ XCTAssertEqual(attachedTab.title, "detached-runtime-title")
+ XCTAssertFalse(attachedTab.hasCustomTitle)
+ }
+
+ func testBrowserSplitWithFocusFalseRecoversFromDelayedStaleSelection() {
+ let workspace = Workspace()
+ guard let originalFocusedPanelId = workspace.focusedPanelId else {
+ XCTFail("Expected initial focused panel")
+ return
+ }
+ guard let originalPaneId = workspace.paneId(forPanelId: originalFocusedPanelId) else {
+ XCTFail("Expected focused pane for initial panel")
+ return
+ }
+
+ guard let browserSplitPanel = workspace.newBrowserSplit(
+ from: originalFocusedPanelId,
+ orientation: .horizontal,
+ focus: false
+ ) else {
+ XCTFail("Expected browser split panel to be created")
+ return
+ }
+ guard let splitPaneId = workspace.paneId(forPanelId: browserSplitPanel.id),
+ let splitTabId = workspace.surfaceIdFromPanelId(browserSplitPanel.id),
+ let splitTab = workspace.bonsplitController
+ .tabs(inPane: splitPaneId)
+ .first(where: { $0.id == splitTabId }) else {
+ XCTFail("Expected split pane/tab mapping")
+ return
+ }
+
+ // Simulate one delayed stale split-selection callback from bonsplit.
+ DispatchQueue.main.async {
+ workspace.splitTabBar(workspace.bonsplitController, didSelectTab: splitTab, inPane: splitPaneId)
+ }
+
+ drainMainQueue()
+ drainMainQueue()
+ drainMainQueue()
+
+ XCTAssertEqual(
+ workspace.focusedPanelId,
+ originalFocusedPanelId,
+ "Expected non-focus split to reassert the pre-split focused panel"
+ )
+ XCTAssertEqual(
+ workspace.bonsplitController.focusedPaneId,
+ originalPaneId,
+ "Expected focused pane to converge back to the pre-split pane"
+ )
+ XCTAssertEqual(
+ workspace.bonsplitController.selectedTab(inPane: originalPaneId)?.id,
+ workspace.surfaceIdFromPanelId(originalFocusedPanelId),
+ "Expected selected tab to converge back to the pre-split focused panel"
+ )
+ }
+
+ func testBrowserSplitWithFocusFalseAllowsSubsequentExplicitFocusOnSplitPanel() {
+ let workspace = Workspace()
+ guard let originalFocusedPanelId = workspace.focusedPanelId else {
+ XCTFail("Expected initial focused panel")
+ return
+ }
+
+ guard let browserSplitPanel = workspace.newBrowserSplit(
+ from: originalFocusedPanelId,
+ orientation: .horizontal,
+ focus: false
+ ) else {
+ XCTFail("Expected browser split panel to be created")
+ return
+ }
+
+ workspace.focusPanel(browserSplitPanel.id)
+
+ drainMainQueue()
+ drainMainQueue()
+ drainMainQueue()
+
+ XCTAssertEqual(
+ workspace.focusedPanelId,
+ browserSplitPanel.id,
+ "Expected explicit focus intent to keep the split panel focused"
+ )
+ }
+
+ func testNewTerminalSurfaceWithFocusFalsePreservesFocusedPanel() {
+ let workspace = Workspace()
+ guard let originalFocusedPanelId = workspace.focusedPanelId,
+ let originalPaneId = workspace.paneId(forPanelId: originalFocusedPanelId) else {
+ XCTFail("Expected initial focused panel and pane")
+ return
+ }
+
+ guard let newPanel = workspace.newTerminalSurface(inPane: originalPaneId, focus: false) else {
+ XCTFail("Expected terminal surface to be created")
+ return
+ }
+
+ drainMainQueue()
+ drainMainQueue()
+ drainMainQueue()
+
+ XCTAssertNotEqual(newPanel.id, originalFocusedPanelId)
+ XCTAssertEqual(
+ workspace.focusedPanelId,
+ originalFocusedPanelId,
+ "Expected non-focus terminal surface creation to preserve the existing focused panel"
+ )
+ XCTAssertEqual(
+ workspace.bonsplitController.selectedTab(inPane: originalPaneId)?.id,
+ workspace.surfaceIdFromPanelId(originalFocusedPanelId),
+ "Expected selected tab to stay on the original focused panel"
+ )
+ }
+
+ func testNewBrowserSurfaceWithFocusFalsePreservesFocusedPanel() {
+ let workspace = Workspace()
+ guard let originalFocusedPanelId = workspace.focusedPanelId,
+ let originalPaneId = workspace.paneId(forPanelId: originalFocusedPanelId) else {
+ XCTFail("Expected initial focused panel and pane")
+ return
+ }
+
+ guard let newPanel = workspace.newBrowserSurface(inPane: originalPaneId, focus: false) else {
+ XCTFail("Expected browser surface to be created")
+ return
+ }
+
+ drainMainQueue()
+ drainMainQueue()
+ drainMainQueue()
+
+ XCTAssertNotEqual(newPanel.id, originalFocusedPanelId)
+ XCTAssertEqual(
+ workspace.focusedPanelId,
+ originalFocusedPanelId,
+ "Expected non-focus browser surface creation to preserve the existing focused panel"
+ )
+ XCTAssertEqual(
+ workspace.bonsplitController.selectedTab(inPane: originalPaneId)?.id,
+ workspace.surfaceIdFromPanelId(originalFocusedPanelId),
+ "Expected selected tab to stay on the original focused panel"
+ )
+ }
+
+ func testClosingFocusedSplitRestoresBranchForRemainingFocusedPanel() {
+ let workspace = Workspace()
+ guard let firstPanelId = workspace.focusedPanelId else {
+ XCTFail("Expected initial focused panel")
+ return
+ }
+
+ workspace.updatePanelGitBranch(panelId: firstPanelId, branch: "main", isDirty: false)
+ guard let secondPanel = workspace.newTerminalSplit(from: firstPanelId, orientation: .horizontal) else {
+ XCTFail("Expected split panel to be created")
+ return
+ }
+
+ workspace.updatePanelGitBranch(panelId: secondPanel.id, branch: "feature/bugfix", isDirty: true)
+ XCTAssertEqual(workspace.focusedPanelId, secondPanel.id, "Expected split panel to be focused")
+ XCTAssertEqual(workspace.gitBranch?.branch, "feature/bugfix")
+ XCTAssertEqual(workspace.gitBranch?.isDirty, true)
+
+ XCTAssertTrue(workspace.closePanel(secondPanel.id, force: true), "Expected split panel close to succeed")
+ XCTAssertEqual(workspace.focusedPanelId, firstPanelId, "Expected surviving panel to become focused")
+ XCTAssertEqual(workspace.gitBranch?.branch, "main")
+ XCTAssertEqual(workspace.gitBranch?.isDirty, false)
+ }
+
+ func testSidebarGitBranchesFollowLeftToRightSplitOrder() {
+ let workspace = Workspace()
+ guard let leftPanelId = workspace.focusedPanelId else {
+ XCTFail("Expected initial focused panel")
+ return
+ }
+
+ workspace.updatePanelGitBranch(panelId: leftPanelId, branch: "main", isDirty: false)
+ guard let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
+ XCTFail("Expected split panel to be created")
+ return
+ }
+ workspace.updatePanelGitBranch(panelId: rightPanel.id, branch: "feature/sidebar", isDirty: true)
+
+ let ordered = workspace.sidebarGitBranchesInDisplayOrder()
+ XCTAssertEqual(ordered.map(\.branch), ["main", "feature/sidebar"])
+ XCTAssertEqual(ordered.map(\.isDirty), [false, true])
+ }
+
+ @MainActor
+ func testSidebarPullRequestsTrackFocusedPanelOnly() {
+ let workspace = Workspace()
+ guard let firstPanelId = workspace.focusedPanelId,
+ let paneId = workspace.paneId(forPanelId: firstPanelId),
+ let secondPanel = workspace.newTerminalSurface(inPane: paneId, focus: false) else {
+ XCTFail("Expected focused panel and a second panel")
+ return
+ }
+
+ workspace.updatePanelGitBranch(panelId: firstPanelId, branch: "main", isDirty: false)
+ workspace.updatePanelGitBranch(panelId: secondPanel.id, branch: "feature/sidebar-pr", isDirty: false)
+ workspace.updatePanelPullRequest(
+ panelId: secondPanel.id,
+ number: 1629,
+ label: "PR",
+ url: URL(string: "https://github.com/manaflow-ai/cmux/pull/1629")!,
+ status: .open
+ )
+
+ XCTAssertNil(workspace.pullRequest)
+ XCTAssertTrue(
+ workspace.sidebarPullRequestsInDisplayOrder().isEmpty,
+ "Expected background panel PRs to stay hidden while the focused panel has no PR"
+ )
+
+ workspace.focusPanel(secondPanel.id)
+
+ XCTAssertEqual(
+ workspace.sidebarPullRequestsInDisplayOrder().map(\.number),
+ [1629]
+ )
+ }
+
+ func testSidebarOrderingUsesPaneOrderThenTabOrderWithBranchDeduping() {
+ let workspace = Workspace()
+ guard let leftFirstPanelId = workspace.focusedPanelId,
+ let leftPaneId = workspace.paneId(forPanelId: leftFirstPanelId),
+ let rightFirstPanel = workspace.newTerminalSplit(from: leftFirstPanelId, orientation: .horizontal),
+ let rightPaneId = workspace.paneId(forPanelId: rightFirstPanel.id),
+ let leftSecondPanel = workspace.newTerminalSurface(inPane: leftPaneId, focus: false),
+ let rightSecondPanel = workspace.newTerminalSurface(inPane: rightPaneId, focus: false) else {
+ XCTFail("Expected panes and panels for ordering test")
+ return
+ }
+
+ XCTAssertTrue(workspace.reorderSurface(panelId: leftFirstPanelId, toIndex: 0))
+ XCTAssertTrue(workspace.reorderSurface(panelId: leftSecondPanel.id, toIndex: 1))
+ XCTAssertTrue(workspace.reorderSurface(panelId: rightFirstPanel.id, toIndex: 0))
+ XCTAssertTrue(workspace.reorderSurface(panelId: rightSecondPanel.id, toIndex: 1))
+
+ workspace.updatePanelGitBranch(panelId: leftFirstPanelId, branch: "main", isDirty: false)
+ workspace.updatePanelGitBranch(panelId: leftSecondPanel.id, branch: "feature/left", isDirty: false)
+ workspace.updatePanelGitBranch(panelId: rightFirstPanel.id, branch: "main", isDirty: true)
+ workspace.updatePanelGitBranch(panelId: rightSecondPanel.id, branch: "feature/right", isDirty: false)
+
+ XCTAssertEqual(
+ workspace.sidebarOrderedPanelIds(),
+ [leftFirstPanelId, leftSecondPanel.id, rightFirstPanel.id, rightSecondPanel.id]
+ )
+
+ let branches = workspace.sidebarGitBranchesInDisplayOrder()
+ XCTAssertEqual(branches.map(\.branch), ["main", "feature/left", "feature/right"])
+ XCTAssertEqual(branches.map(\.isDirty), [true, false, false])
+ }
+
+ func testSidebarDerivedCollectionsMatchWhenUsingPrecomputedPanelOrder() {
+ let workspace = Workspace()
+ guard let leftFirstPanelId = workspace.focusedPanelId,
+ let leftPaneId = workspace.paneId(forPanelId: leftFirstPanelId),
+ let rightFirstPanel = workspace.newTerminalSplit(from: leftFirstPanelId, orientation: .horizontal),
+ let rightPaneId = workspace.paneId(forPanelId: rightFirstPanel.id),
+ let leftSecondPanel = workspace.newTerminalSurface(inPane: leftPaneId, focus: false),
+ let rightSecondPanel = workspace.newTerminalSurface(inPane: rightPaneId, focus: false) else {
+ XCTFail("Expected panes and panels for precomputed ordering test")
+ return
+ }
+
+ workspace.updatePanelGitBranch(panelId: leftFirstPanelId, branch: "main", isDirty: false)
+ workspace.updatePanelGitBranch(panelId: leftSecondPanel.id, branch: "feature/left", isDirty: true)
+ workspace.updatePanelGitBranch(panelId: rightFirstPanel.id, branch: "release/right", isDirty: false)
+
+ workspace.updatePanelDirectory(panelId: leftFirstPanelId, directory: "/repo/left/root")
+ workspace.updatePanelDirectory(panelId: leftSecondPanel.id, directory: "/repo/left/feature")
+ workspace.updatePanelDirectory(panelId: rightFirstPanel.id, directory: "/repo/right/root")
+ workspace.updatePanelDirectory(panelId: rightSecondPanel.id, directory: "/repo/right/extra")
+
+ workspace.updatePanelPullRequest(
+ panelId: leftFirstPanelId,
+ number: 101,
+ label: "PR",
+ url: URL(string: "https://github.com/manaflow-ai/cmux/pull/101")!,
+ status: .open
+ )
+ workspace.updatePanelPullRequest(
+ panelId: rightFirstPanel.id,
+ number: 18,
+ label: "MR",
+ url: URL(string: "https://gitlab.com/manaflow/cmux/-/merge_requests/18")!,
+ status: .merged
+ )
+
+ let orderedPanelIds = workspace.sidebarOrderedPanelIds()
+
+ XCTAssertEqual(
+ workspace.sidebarGitBranchesInDisplayOrder(orderedPanelIds: orderedPanelIds).map { "\($0.branch)|\($0.isDirty)" },
+ workspace.sidebarGitBranchesInDisplayOrder().map { "\($0.branch)|\($0.isDirty)" }
+ )
+ XCTAssertEqual(
+ workspace.sidebarBranchDirectoryEntriesInDisplayOrder(orderedPanelIds: orderedPanelIds),
+ workspace.sidebarBranchDirectoryEntriesInDisplayOrder()
+ )
+ XCTAssertEqual(
+ workspace.sidebarPullRequestsInDisplayOrder(orderedPanelIds: orderedPanelIds),
+ workspace.sidebarPullRequestsInDisplayOrder()
+ )
+ }
+
+ func testClosingPaneDropsBranchesFromClosedSide() {
+ let workspace = Workspace()
+ guard let leftPanelId = workspace.focusedPanelId,
+ let leftPaneId = workspace.paneId(forPanelId: leftPanelId),
+ let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
+ XCTFail("Expected left/right split panes")
+ return
+ }
+
+ workspace.updatePanelGitBranch(panelId: leftPanelId, branch: "branch1", isDirty: false)
+ workspace.updatePanelGitBranch(panelId: rightPanel.id, branch: "branch2", isDirty: false)
+
+ XCTAssertEqual(workspace.sidebarGitBranchesInDisplayOrder().map(\.branch), ["branch1", "branch2"])
+ XCTAssertTrue(workspace.bonsplitController.closePane(leftPaneId))
+ XCTAssertEqual(workspace.sidebarGitBranchesInDisplayOrder().map(\.branch), ["branch2"])
+ }
+}
+
+
+final class WorkspaceMountPolicyTests: XCTestCase {
+ func testDefaultPolicyMountsOnlySelectedWorkspace() {
+ let a = UUID()
+ let b = UUID()
+ let orderedTabIds: [UUID] = [a, b]
+
+ let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
+ current: [a],
+ selected: b,
+ pinnedIds: [],
+ orderedTabIds: orderedTabIds,
+ isCycleHot: false,
+ maxMounted: WorkspaceMountPolicy.maxMountedWorkspaces
+ )
+
+ XCTAssertEqual(next, [b])
+ }
+
+ func testSelectedWorkspaceMovesToFrontAndMountCountIsBounded() {
+ let a = UUID()
+ let b = UUID()
+ let c = UUID()
+ let orderedTabIds: [UUID] = [a, b, c]
+
+ let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
+ current: [a, b, c],
+ selected: c,
+ pinnedIds: [],
+ orderedTabIds: orderedTabIds,
+ isCycleHot: false,
+ maxMounted: 2
+ )
+
+ XCTAssertEqual(next, [c, a])
+ }
+
+ func testMissingWorkspacesArePruned() {
+ let a = UUID()
+ let b = UUID()
+
+ let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
+ current: [b, a],
+ selected: nil,
+ pinnedIds: [],
+ orderedTabIds: [a],
+ isCycleHot: false,
+ maxMounted: 2
+ )
+
+ XCTAssertEqual(next, [a])
+ }
+
+ func testSelectedWorkspaceIsInsertedWhenAbsentFromCurrentCache() {
+ let a = UUID()
+ let b = UUID()
+ let orderedTabIds: [UUID] = [a, b]
+
+ let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
+ current: [a],
+ selected: b,
+ pinnedIds: [],
+ orderedTabIds: orderedTabIds,
+ isCycleHot: false,
+ maxMounted: 2
+ )
+
+ XCTAssertEqual(next, [b, a])
+ }
+
+ func testMaxMountedIsClampedToAtLeastOne() {
+ let a = UUID()
+ let b = UUID()
+ let orderedTabIds: [UUID] = [a, b]
+
+ let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
+ current: [a, b],
+ selected: nil,
+ pinnedIds: [],
+ orderedTabIds: orderedTabIds,
+ isCycleHot: false,
+ maxMounted: 0
+ )
+
+ XCTAssertEqual(next, [a])
+ }
+
+ func testCycleHotModeKeepsOnlySelectedWhenNoPinnedHandoff() {
+ let a = UUID()
+ let b = UUID()
+ let c = UUID()
+ let d = UUID()
+ let orderedTabIds: [UUID] = [a, b, c, d]
+
+ let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
+ current: [a],
+ selected: c,
+ pinnedIds: [],
+ orderedTabIds: orderedTabIds,
+ isCycleHot: true,
+ maxMounted: WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle
+ )
+
+ XCTAssertEqual(next, [c])
+ }
+
+ func testCycleHotModeRespectsMaxMountedLimit() {
+ let a = UUID()
+ let b = UUID()
+ let c = UUID()
+ let orderedTabIds: [UUID] = [a, b, c]
+
+ let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
+ current: [a, b, c],
+ selected: b,
+ pinnedIds: [],
+ orderedTabIds: orderedTabIds,
+ isCycleHot: true,
+ maxMounted: 2
+ )
+
+ XCTAssertEqual(next, [b])
+ }
+
+ func testPinnedIdsAreRetainedAcrossReconcile() {
+ let a = UUID()
+ let b = UUID()
+ let c = UUID()
+ let orderedTabIds: [UUID] = [a, b, c]
+
+ let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
+ current: [a],
+ selected: c,
+ pinnedIds: [a],
+ orderedTabIds: orderedTabIds,
+ isCycleHot: false,
+ maxMounted: 2
+ )
+
+ XCTAssertEqual(next, [c, a])
+ }
+
+ func testCycleHotModeKeepsRetiringWorkspaceWhenPinned() {
+ let a = UUID()
+ let b = UUID()
+ let orderedTabIds: [UUID] = [a, b]
+
+ let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
+ current: [a],
+ selected: b,
+ pinnedIds: [a],
+ orderedTabIds: orderedTabIds,
+ isCycleHot: true,
+ maxMounted: WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle
+ )
+
+ XCTAssertEqual(next, [b, a])
+ }
+}
+
+
+@MainActor
+final class SidebarWorkspaceShortcutHintMetricsTests: XCTestCase {
+ override func setUp() {
+ super.setUp()
+ SidebarWorkspaceShortcutHintMetrics.resetCacheForTesting()
+ }
+
+ override func tearDown() {
+ SidebarWorkspaceShortcutHintMetrics.resetCacheForTesting()
+ super.tearDown()
+ }
+
+ func testHintWidthCachesRepeatedMeasurements() {
+ XCTAssertEqual(SidebarWorkspaceShortcutHintMetrics.measurementCountForTesting(), 0)
+
+ let first = SidebarWorkspaceShortcutHintMetrics.hintWidth(for: "⌘1")
+ XCTAssertGreaterThan(first, 0)
+ XCTAssertEqual(SidebarWorkspaceShortcutHintMetrics.measurementCountForTesting(), 1)
+
+ let second = SidebarWorkspaceShortcutHintMetrics.hintWidth(for: "⌘1")
+ XCTAssertEqual(second, first)
+ XCTAssertEqual(SidebarWorkspaceShortcutHintMetrics.measurementCountForTesting(), 1)
+
+ _ = SidebarWorkspaceShortcutHintMetrics.hintWidth(for: "⌘2")
+ XCTAssertEqual(SidebarWorkspaceShortcutHintMetrics.measurementCountForTesting(), 2)
+ }
+
+ func testSlotWidthAppliesMinimumAndDebugInset() {
+ let nilLabelWidth = SidebarWorkspaceShortcutHintMetrics.slotWidth(label: nil, debugXOffset: 999)
+ XCTAssertEqual(nilLabelWidth, 28)
+
+ let base = SidebarWorkspaceShortcutHintMetrics.slotWidth(label: "⌘1", debugXOffset: 0)
+ let widened = SidebarWorkspaceShortcutHintMetrics.slotWidth(label: "⌘1", debugXOffset: 10)
+ XCTAssertGreaterThan(widened, base)
+ }
+}
+#endif
From 629b63dfb865fa53f091842b9e8423da1132db81 Mon Sep 17 00:00:00 2001
From: Austin Wang
Date: Wed, 18 Mar 2026 01:23:53 -0700
Subject: [PATCH 07/24] fix: repair NIGHTLY Sparkle quarantine metadata (#1703)
* test: add quarantine regression coverage
* fix: repair Sparkle quarantine metadata for nightly updates
* fix: repair extracted Sparkle app on extraction callbacks
---
GhosttyTabs.xcodeproj/project.pbxproj | 10 +-
Sources/Update/UpdateDelegate.swift | 21 ++
Sources/Update/UpdateDriver.swift | 27 ++
Sources/Update/UpdateQuarantineRepair.swift | 292 ++++++++++++++++++++
cmuxTests/UpdateQuarantineRepairTests.swift | 173 ++++++++++++
5 files changed, 522 insertions(+), 1 deletion(-)
create mode 100644 Sources/Update/UpdateQuarantineRepair.swift
create mode 100644 cmuxTests/UpdateQuarantineRepairTests.swift
diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj
index 0a4da569..90ef397b 100644
--- a/GhosttyTabs.xcodeproj/project.pbxproj
+++ b/GhosttyTabs.xcodeproj/project.pbxproj
@@ -98,7 +98,9 @@
F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */; };
FA000000A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */; };
A5008381 /* BrowserFindJavaScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008380 /* BrowserFindJavaScriptTests.swift */; };
- A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008382 /* CommandPaletteSearchEngineTests.swift */; };
+ A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008382 /* CommandPaletteSearchEngineTests.swift */; };
+ AB169902A1B2C3D4E5F60718 /* UpdateQuarantineRepairTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB169903A1B2C3D4E5F60718 /* UpdateQuarantineRepairTests.swift */; };
+ AB169900A1B2C3D4E5F60718 /* UpdateQuarantineRepair.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB169901A1B2C3D4E5F60718 /* UpdateQuarantineRepair.swift */; };
DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000001 /* Localizable.xcstrings */; };
DA7A10CA710E000000000004 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000002 /* InfoPlist.xcstrings */; };
A5001623 /* cmux.sdef in Resources */ = {isa = PBXBuildFile; fileRef = A5001622 /* cmux.sdef */; };
@@ -263,6 +265,8 @@
FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStressProfileTests.swift; sourceTree = ""; };
A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = ""; };
A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = ""; };
+ AB169903A1B2C3D4E5F60718 /* UpdateQuarantineRepairTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateQuarantineRepairTests.swift; sourceTree = ""; };
+ AB169901A1B2C3D4E5F60718 /* UpdateQuarantineRepair.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateQuarantineRepair.swift; sourceTree = ""; };
DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; };
DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; };
A5001622 /* cmux.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = cmux.sdef; sourceTree = ""; };
@@ -438,6 +442,7 @@
A5001221 /* UpdateTestSupport.swift */,
A5001224 /* UpdateTestURLProtocol.swift */,
A5001223 /* UpdateLogStore.swift */,
+ AB169901A1B2C3D4E5F60718 /* UpdateQuarantineRepair.swift */,
A5001217 /* UpdatePopoverView.swift */,
A5001218 /* UpdateTitlebarAccessory.swift */,
A5001219 /* WindowToolbarController.swift */,
@@ -516,6 +521,7 @@
FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */,
A5008380 /* BrowserFindJavaScriptTests.swift */,
A5008382 /* CommandPaletteSearchEngineTests.swift */,
+ AB169903A1B2C3D4E5F60718 /* UpdateQuarantineRepairTests.swift */,
970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */,
58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */,
02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */,
@@ -728,6 +734,7 @@
A500120B /* UpdateTestSupport.swift in Sources */,
A500120E /* UpdateTestURLProtocol.swift in Sources */,
A500120D /* UpdateLogStore.swift in Sources */,
+ AB169900A1B2C3D4E5F60718 /* UpdateQuarantineRepair.swift in Sources */,
A5001207 /* UpdatePopoverView.swift in Sources */,
A5001208 /* UpdateTitlebarAccessory.swift in Sources */,
A5001209 /* WindowToolbarController.swift in Sources */,
@@ -774,6 +781,7 @@
FA000000A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift in Sources */,
A5008381 /* BrowserFindJavaScriptTests.swift in Sources */,
A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */,
+ AB169902A1B2C3D4E5F60718 /* UpdateQuarantineRepairTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
diff --git a/Sources/Update/UpdateDelegate.swift b/Sources/Update/UpdateDelegate.swift
index 7de114d3..2fb1cd5e 100644
--- a/Sources/Update/UpdateDelegate.swift
+++ b/Sources/Update/UpdateDelegate.swift
@@ -79,6 +79,20 @@ extension UpdateDriver: SPUUpdaterDelegate {
}
}
+ func updater(_ updater: SPUUpdater, willExtractUpdate item: SUAppcastItem) {
+ prepareQuarantineRepair(for: item.fileURL)
+ do {
+ let result = try UpdateQuarantineRepair.repairDownloadedArchiveIfNeeded(
+ hostName: UpdateQuarantineRepair.sparkleHostName(),
+ versionString: item.versionString,
+ dataURL: item.fileURL
+ )
+ logUpdateQuarantineRepair(stage: "download", result: result)
+ } catch {
+ UpdateLogStore.shared.append("quarantine repair download failed: \(error.localizedDescription)")
+ }
+ }
+
func updaterDidNotFindUpdate(_ updater: SPUUpdater, error: Error) {
viewModel.clearDetectedUpdate()
let nsError = error as NSError
@@ -111,6 +125,13 @@ extension UpdateDriver: SPUUpdaterDelegate {
}
}
+private func logUpdateQuarantineRepair(stage: String, result: UpdateQuarantineRepairResult) {
+ let path = result.url?.path ?? ""
+ let before = result.beforeRawValue ?? ""
+ let after = result.afterRawValue ?? ""
+ UpdateLogStore.shared.append("quarantine repair \(stage): \(result.outcome) path=\(path) before=\(before) after=\(after)")
+}
+
private func describeNoUpdateFoundReason(_ reason: SPUNoUpdateFoundReason) -> String {
switch reason {
case .unknown:
diff --git a/Sources/Update/UpdateDriver.swift b/Sources/Update/UpdateDriver.swift
index 289df890..fc81b6ba 100644
--- a/Sources/Update/UpdateDriver.swift
+++ b/Sources/Update/UpdateDriver.swift
@@ -9,6 +9,8 @@ class UpdateDriver: NSObject, SPUUserDriver {
private var pendingCheckTransition: DispatchWorkItem?
private var checkTimeoutWorkItem: DispatchWorkItem?
private var lastFeedURLString: String?
+ private var updateFileURLForQuarantineRepair: URL?
+ private var finishedExtractedUpdateQuarantineRepair: Bool = false
init(viewModel: UpdateViewModel, hostBundle _: Bundle) {
self.viewModel = viewModel
@@ -118,11 +120,13 @@ class UpdateDriver: NSObject, SPUUserDriver {
func showDownloadDidStartExtractingUpdate() {
UpdateLogStore.shared.append("show extraction started")
setState(.extracting(.init(progress: 0)))
+ maybeRepairExtractedUpdateQuarantine()
}
func showExtractionReceivedProgress(_ progress: Double) {
UpdateLogStore.shared.append(String(format: "show extraction progress: %.2f", progress))
setState(.extracting(.init(progress: progress)))
+ maybeRepairExtractedUpdateQuarantine()
}
func showReady(toInstallAndRelaunch reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) {
@@ -254,6 +258,11 @@ class UpdateDriver: NSObject, SPUUserDriver {
UpdateLogStore.shared.append("feed url resolved\(suffix): \(feedURLString)")
}
+ func prepareQuarantineRepair(for updateFileURL: URL?) {
+ updateFileURLForQuarantineRepair = updateFileURL
+ finishedExtractedUpdateQuarantineRepair = false
+ }
+
func formatErrorForLog(_ error: Error) -> String {
let nsError = error as NSError
var parts: [String] = ["\(nsError.domain)(\(nsError.code))"]
@@ -302,6 +311,24 @@ class UpdateDriver: NSObject, SPUUserDriver {
}
}
+ private func maybeRepairExtractedUpdateQuarantine() {
+ guard !finishedExtractedUpdateQuarantineRepair else { return }
+
+ do {
+ let result = try UpdateQuarantineRepair.repairExtractedApplicationIfNeeded(dataURL: updateFileURLForQuarantineRepair)
+ guard result.outcome != .notFound else { return }
+
+ finishedExtractedUpdateQuarantineRepair = true
+ let path = result.url?.path ?? ""
+ let before = result.beforeRawValue ?? ""
+ let after = result.afterRawValue ?? ""
+ UpdateLogStore.shared.append("quarantine repair extracted-app: \(result.outcome) path=\(path) before=\(before) after=\(after)")
+ } catch {
+ finishedExtractedUpdateQuarantineRepair = true
+ UpdateLogStore.shared.append("quarantine repair extracted-app failed: \(error.localizedDescription)")
+ }
+ }
+
private func runOnMain(_ action: @escaping () -> Void) {
if Thread.isMainThread {
action()
diff --git a/Sources/Update/UpdateQuarantineRepair.swift b/Sources/Update/UpdateQuarantineRepair.swift
new file mode 100644
index 00000000..423c2981
--- /dev/null
+++ b/Sources/Update/UpdateQuarantineRepair.swift
@@ -0,0 +1,292 @@
+import CoreServices
+import Darwin
+import Foundation
+
+enum UpdateQuarantineRepairOutcome: Equatable {
+ case skipped
+ case notFound
+ case notQuarantined
+ case alreadyValid
+ case repaired
+}
+
+struct UpdateQuarantineRepairResult {
+ let outcome: UpdateQuarantineRepairOutcome
+ let url: URL?
+ let beforeRawValue: String?
+ let afterRawValue: String?
+}
+
+enum UpdateQuarantineRepair {
+ static let sparkleCacheDirectoryName = "org.sparkle-project.Sparkle"
+ static let persistentDownloadsDirectoryName = "PersistentDownloads"
+ static let installationDirectoryName = "Installation"
+
+ private static let quarantineAttributeName = "com.apple.quarantine"
+
+ static func sparkleHostName(for bundle: Bundle = .main, fileManager: FileManager = .default) -> String {
+ for key in ["SUBundleName", "CFBundleDisplayName", kCFBundleNameKey as String] {
+ if let value = bundle.object(forInfoDictionaryKey: key) as? String,
+ !value.isEmpty {
+ return value
+ }
+ }
+ return (fileManager.displayName(atPath: bundle.bundlePath) as NSString).deletingPathExtension
+ }
+
+ static func persistentDownloadsRootURL(bundleIdentifier: String, cachesDirectory: URL? = nil) -> URL {
+ let base = cachesDirectory ?? FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first ?? FileManager.default.temporaryDirectory
+ return base
+ .appendingPathComponent(bundleIdentifier, isDirectory: true)
+ .appendingPathComponent(sparkleCacheDirectoryName, isDirectory: true)
+ .appendingPathComponent(persistentDownloadsDirectoryName, isDirectory: true)
+ }
+
+ static func installationRootURL(bundleIdentifier: String, cachesDirectory: URL? = nil) -> URL {
+ let base = cachesDirectory ?? FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first ?? FileManager.default.temporaryDirectory
+ return base
+ .appendingPathComponent(bundleIdentifier, isDirectory: true)
+ .appendingPathComponent(sparkleCacheDirectoryName, isDirectory: true)
+ .appendingPathComponent(installationDirectoryName, isDirectory: true)
+ }
+
+ static func locateDownloadedArchive(
+ bundleIdentifier: String,
+ hostName: String,
+ versionString: String,
+ cachesDirectory: URL? = nil,
+ fileManager: FileManager = .default
+ ) -> URL? {
+ let rootURL = persistentDownloadsRootURL(bundleIdentifier: bundleIdentifier, cachesDirectory: cachesDirectory)
+ let expectedDirectoryName = (hostName.isEmpty || versionString.isEmpty) ? nil : "\(hostName) \(versionString)"
+
+ if let exactMatch = newestItem(
+ in: rootURL,
+ fileManager: fileManager,
+ skipPackageDescendants: true,
+ matching: { url, values, _ in
+ guard values.isRegularFile == true else { return false }
+ guard let expectedDirectoryName else { return true }
+ return url.deletingLastPathComponent().lastPathComponent == expectedDirectoryName
+ }
+ ) {
+ return exactMatch
+ }
+
+ return newestItem(in: rootURL, fileManager: fileManager, skipPackageDescendants: true) { _, values, _ in
+ values.isRegularFile == true
+ }
+ }
+
+ static func locateExtractedApplication(
+ bundleIdentifier: String,
+ bundleName: String,
+ cachesDirectory: URL? = nil,
+ fileManager: FileManager = .default
+ ) -> URL? {
+ let rootURL = installationRootURL(bundleIdentifier: bundleIdentifier, cachesDirectory: cachesDirectory)
+ let expectedBundleName = bundleName.isEmpty ? nil : bundleName
+
+ if let exactMatch = newestItem(
+ in: rootURL,
+ fileManager: fileManager,
+ skipPackageDescendants: true,
+ matching: { url, values, _ in
+ guard values.isDirectory == true, url.pathExtension == "app" else { return false }
+ guard let expectedBundleName else { return true }
+ return url.lastPathComponent == expectedBundleName
+ }
+ ) {
+ return exactMatch
+ }
+
+ return newestItem(in: rootURL, fileManager: fileManager, skipPackageDescendants: true) { url, values, _ in
+ values.isDirectory == true && url.pathExtension == "app"
+ }
+ }
+
+ static func repairDownloadedArchiveIfNeeded(
+ hostName: String,
+ versionString: String,
+ bundle: Bundle = .main,
+ fileManager: FileManager = .default,
+ cachesDirectory: URL? = nil,
+ dataURL: URL? = nil
+ ) throws -> UpdateQuarantineRepairResult {
+ guard let bundleIdentifier = bundle.bundleIdentifier else {
+ return .init(outcome: .skipped, url: nil, beforeRawValue: nil, afterRawValue: nil)
+ }
+
+ guard let archiveURL = locateDownloadedArchive(
+ bundleIdentifier: bundleIdentifier,
+ hostName: hostName,
+ versionString: versionString,
+ cachesDirectory: cachesDirectory,
+ fileManager: fileManager
+ ) else {
+ return .init(outcome: .notFound, url: nil, beforeRawValue: nil, afterRawValue: nil)
+ }
+
+ return try repairQuarantineIfNeeded(
+ at: archiveURL,
+ agentBundleIdentifier: bundleIdentifier,
+ agentName: sparkleHostName(for: bundle, fileManager: fileManager),
+ dataURL: dataURL
+ )
+ }
+
+ static func repairExtractedApplicationIfNeeded(
+ bundle: Bundle = .main,
+ fileManager: FileManager = .default,
+ cachesDirectory: URL? = nil,
+ dataURL: URL? = nil
+ ) throws -> UpdateQuarantineRepairResult {
+ guard let bundleIdentifier = bundle.bundleIdentifier else {
+ return .init(outcome: .skipped, url: nil, beforeRawValue: nil, afterRawValue: nil)
+ }
+
+ guard let appURL = locateExtractedApplication(
+ bundleIdentifier: bundleIdentifier,
+ bundleName: bundle.bundleURL.lastPathComponent,
+ cachesDirectory: cachesDirectory,
+ fileManager: fileManager
+ ) else {
+ return .init(outcome: .notFound, url: nil, beforeRawValue: nil, afterRawValue: nil)
+ }
+
+ return try repairQuarantineIfNeeded(
+ at: appURL,
+ agentBundleIdentifier: bundleIdentifier,
+ agentName: sparkleHostName(for: bundle, fileManager: fileManager),
+ dataURL: dataURL
+ )
+ }
+
+ static func repairQuarantineIfNeeded(
+ at url: URL,
+ agentBundleIdentifier: String,
+ agentName: String,
+ dataURL: URL? = nil
+ ) throws -> UpdateQuarantineRepairResult {
+ let beforeRawValue = rawQuarantineAttribute(at: url)
+ var resourceValues = try url.resourceValues(forKeys: [.quarantinePropertiesKey])
+ var quarantineProperties = resourceValues.quarantineProperties ?? [:]
+
+ let hasQuarantine = beforeRawValue != nil || !quarantineProperties.isEmpty
+ guard hasQuarantine else {
+ return .init(outcome: .notQuarantined, url: url, beforeRawValue: beforeRawValue, afterRawValue: beforeRawValue)
+ }
+
+ var didChange = false
+
+ let existingBundleIdentifier = (quarantineProperties[kLSQuarantineAgentBundleIdentifierKey as String] as? String)?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ if existingBundleIdentifier != agentBundleIdentifier {
+ quarantineProperties[kLSQuarantineAgentBundleIdentifierKey as String] = agentBundleIdentifier
+ didChange = true
+ }
+
+ let existingAgentName = (quarantineProperties[kLSQuarantineAgentNameKey as String] as? String)?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ if existingAgentName != agentName {
+ quarantineProperties[kLSQuarantineAgentNameKey as String] = agentName
+ didChange = true
+ }
+
+ if quarantineProperties[kLSQuarantineTypeKey as String] == nil {
+ quarantineProperties[kLSQuarantineTypeKey as String] = inferredQuarantineType(for: dataURL)
+ didChange = true
+ }
+
+ if let dataURL, quarantineProperties[kLSQuarantineDataURLKey as String] == nil {
+ quarantineProperties[kLSQuarantineDataURLKey as String] = dataURL
+ didChange = true
+ }
+
+ if !didChange, let beforeRawValue, rawQuarantineNeedsLaunchServicesRepair(beforeRawValue) {
+ didChange = true
+ }
+
+ guard didChange else {
+ return .init(outcome: .alreadyValid, url: url, beforeRawValue: beforeRawValue, afterRawValue: beforeRawValue)
+ }
+
+ resourceValues.quarantineProperties = quarantineProperties
+ var mutableURL = url
+ try mutableURL.setResourceValues(resourceValues)
+
+ let afterRawValue = rawQuarantineAttribute(at: url)
+ return .init(outcome: .repaired, url: url, beforeRawValue: beforeRawValue, afterRawValue: afterRawValue)
+ }
+
+ static func rawQuarantineAttribute(at url: URL) -> String? {
+ url.path.withCString { pathPointer in
+ quarantineAttributeName.withCString { attributePointer in
+ let size = getxattr(pathPointer, attributePointer, nil, 0, 0, XATTR_NOFOLLOW)
+ guard size >= 0 else { return nil }
+
+ var buffer = [UInt8](repeating: 0, count: Int(size))
+ let bytesRead = getxattr(pathPointer, attributePointer, &buffer, buffer.count, 0, XATTR_NOFOLLOW)
+ guard bytesRead >= 0 else { return nil }
+
+ return String(decoding: buffer.prefix(Int(bytesRead)), as: UTF8.self)
+ }
+ }
+ }
+
+ static func rawQuarantineNeedsLaunchServicesRepair(_ rawValue: String) -> Bool {
+ let components = rawValue.split(separator: ";", omittingEmptySubsequences: false)
+ guard components.count >= 4 else { return true }
+ return components[3].isEmpty
+ }
+
+ private static func inferredQuarantineType(for dataURL: URL?) -> String {
+ guard let scheme = dataURL?.scheme?.lowercased() else {
+ return kLSQuarantineTypeOtherDownload as String
+ }
+ switch scheme {
+ case "http", "https":
+ return kLSQuarantineTypeWebDownload as String
+ default:
+ return kLSQuarantineTypeOtherDownload as String
+ }
+ }
+
+ private static func newestItem(
+ in rootURL: URL,
+ fileManager: FileManager,
+ skipPackageDescendants: Bool,
+ matching predicate: (URL, URLResourceValues, FileManager.DirectoryEnumerator) -> Bool
+ ) -> URL? {
+ guard fileManager.fileExists(atPath: rootURL.path) else { return nil }
+
+ let keys: [URLResourceKey] = [.contentModificationDateKey, .isRegularFileKey, .isDirectoryKey]
+ guard let enumerator = fileManager.enumerator(
+ at: rootURL,
+ includingPropertiesForKeys: keys,
+ options: [.skipsHiddenFiles],
+ errorHandler: nil
+ ) else {
+ return nil
+ }
+
+ var newestURL: URL?
+ var newestDate = Date.distantPast
+
+ for case let candidateURL as URL in enumerator {
+ let resourceValues = (try? candidateURL.resourceValues(forKeys: Set(keys))) ?? URLResourceValues()
+ if skipPackageDescendants && (candidateURL.pathExtension == "app" || candidateURL.pathExtension == "pkg") {
+ enumerator.skipDescendants()
+ }
+ guard predicate(candidateURL, resourceValues, enumerator) else { continue }
+
+ let contentModificationDate = resourceValues.contentModificationDate ?? Date.distantPast
+ if newestURL == nil || contentModificationDate > newestDate {
+ newestURL = candidateURL
+ newestDate = contentModificationDate
+ }
+ }
+
+ return newestURL
+ }
+}
diff --git a/cmuxTests/UpdateQuarantineRepairTests.swift b/cmuxTests/UpdateQuarantineRepairTests.swift
new file mode 100644
index 00000000..04c4e97a
--- /dev/null
+++ b/cmuxTests/UpdateQuarantineRepairTests.swift
@@ -0,0 +1,173 @@
+import CoreServices
+import Darwin
+import Foundation
+import XCTest
+
+#if canImport(cmux_DEV)
+@testable import cmux_DEV
+#elseif canImport(cmux)
+@testable import cmux
+#endif
+
+final class UpdateQuarantineRepairTests: XCTestCase {
+ func testRepairAddsLaunchServicesMetadataForMissingAgentBundleIdentifier() throws {
+ let fileURL = try makeTemporaryFile(named: "cmux-nightly.dmg")
+ try writeRawQuarantine("0383;69ba4249;;", to: fileURL)
+
+ let beforeRawValue = try XCTUnwrap(UpdateQuarantineRepair.rawQuarantineAttribute(at: fileURL))
+ XCTAssertEqual(beforeRawValue, "0383;69ba4249;;")
+
+ let result = try UpdateQuarantineRepair.repairQuarantineIfNeeded(
+ at: fileURL,
+ agentBundleIdentifier: "com.cmuxterm.app.nightly",
+ agentName: "cmux NIGHTLY",
+ dataURL: URL(string: "https://example.com/cmux-nightly-macos.dmg")
+ )
+
+ XCTAssertEqual(result.outcome, .repaired)
+ let afterRawValue = try XCTUnwrap(UpdateQuarantineRepair.rawQuarantineAttribute(at: fileURL))
+ XCTAssertNotEqual(afterRawValue, beforeRawValue)
+ XCTAssertFalse(UpdateQuarantineRepair.rawQuarantineNeedsLaunchServicesRepair(afterRawValue))
+
+ let properties = try fileURL.resourceValues(forKeys: [.quarantinePropertiesKey]).quarantineProperties
+ XCTAssertEqual(properties?[kLSQuarantineAgentBundleIdentifierKey as String] as? String, "com.cmuxterm.app.nightly")
+ XCTAssertEqual(properties?[kLSQuarantineAgentNameKey as String] as? String, "cmux NIGHTLY")
+ XCTAssertEqual(properties?[kLSQuarantineTypeKey as String] as? String, kLSQuarantineTypeWebDownload as String)
+ }
+
+ func testRepairIsNoOpWhenLaunchServicesQuarantineRecordIsAlreadyValid() throws {
+ let fileURL = try makeTemporaryFile(named: "cmux-nightly.dmg")
+ try writeRawQuarantine("0383;69ba4249;;", to: fileURL)
+
+ _ = try UpdateQuarantineRepair.repairQuarantineIfNeeded(
+ at: fileURL,
+ agentBundleIdentifier: "com.cmuxterm.app.nightly",
+ agentName: "cmux NIGHTLY",
+ dataURL: URL(string: "https://example.com/cmux-nightly-macos.dmg")
+ )
+
+ let repairedRawValue = try XCTUnwrap(UpdateQuarantineRepair.rawQuarantineAttribute(at: fileURL))
+ let secondResult = try UpdateQuarantineRepair.repairQuarantineIfNeeded(
+ at: fileURL,
+ agentBundleIdentifier: "com.cmuxterm.app.nightly",
+ agentName: "cmux NIGHTLY",
+ dataURL: URL(string: "https://example.com/cmux-nightly-macos.dmg")
+ )
+
+ XCTAssertEqual(secondResult.outcome, .alreadyValid)
+ XCTAssertEqual(secondResult.beforeRawValue, repairedRawValue)
+ XCTAssertEqual(secondResult.afterRawValue, repairedRawValue)
+ }
+
+ func testLocateDownloadedArchivePrefersNewestMatchingVersionDirectory() throws {
+ let cachesDirectory = try makeTemporaryDirectory(named: "SparkleCaches")
+ let rootURL = UpdateQuarantineRepair.persistentDownloadsRootURL(
+ bundleIdentifier: "com.cmuxterm.app.nightly",
+ cachesDirectory: cachesDirectory
+ )
+
+ let oldArchiveURL = rootURL
+ .appendingPathComponent("token-old", isDirectory: true)
+ .appendingPathComponent("cmux NIGHTLY 1234", isDirectory: true)
+ .appendingPathComponent("old.dmg")
+ let newArchiveURL = rootURL
+ .appendingPathComponent("token-new", isDirectory: true)
+ .appendingPathComponent("cmux NIGHTLY 1234", isDirectory: true)
+ .appendingPathComponent("new.dmg")
+ let otherArchiveURL = rootURL
+ .appendingPathComponent("token-other", isDirectory: true)
+ .appendingPathComponent("cmux NIGHTLY 9999", isDirectory: true)
+ .appendingPathComponent("other.dmg")
+
+ try createFile(at: oldArchiveURL)
+ try createFile(at: newArchiveURL)
+ try createFile(at: otherArchiveURL)
+
+ try setModificationDate(Date(timeIntervalSince1970: 100), for: oldArchiveURL)
+ try setModificationDate(Date(timeIntervalSince1970: 200), for: newArchiveURL)
+ try setModificationDate(Date(timeIntervalSince1970: 300), for: otherArchiveURL)
+
+ let locatedArchiveURL = UpdateQuarantineRepair.locateDownloadedArchive(
+ bundleIdentifier: "com.cmuxterm.app.nightly",
+ hostName: "cmux NIGHTLY",
+ versionString: "1234",
+ cachesDirectory: cachesDirectory
+ )
+
+ XCTAssertEqual(locatedArchiveURL, newArchiveURL)
+ }
+
+ func testLocateExtractedApplicationUsesNewestMatchingBundleName() throws {
+ let cachesDirectory = try makeTemporaryDirectory(named: "SparkleInstallation")
+ let rootURL = UpdateQuarantineRepair.installationRootURL(
+ bundleIdentifier: "com.cmuxterm.app.nightly",
+ cachesDirectory: cachesDirectory
+ )
+
+ let oldAppURL = rootURL
+ .appendingPathComponent("install-old", isDirectory: true)
+ .appendingPathComponent("extract-old", isDirectory: true)
+ .appendingPathComponent("cmux NIGHTLY.app", isDirectory: true)
+ let newAppURL = rootURL
+ .appendingPathComponent("install-new", isDirectory: true)
+ .appendingPathComponent("extract-new", isDirectory: true)
+ .appendingPathComponent("cmux NIGHTLY.app", isDirectory: true)
+ let otherAppURL = rootURL
+ .appendingPathComponent("install-other", isDirectory: true)
+ .appendingPathComponent("extract-other", isDirectory: true)
+ .appendingPathComponent("Different.app", isDirectory: true)
+
+ try FileManager.default.createDirectory(at: oldAppURL, withIntermediateDirectories: true)
+ try FileManager.default.createDirectory(at: newAppURL, withIntermediateDirectories: true)
+ try FileManager.default.createDirectory(at: otherAppURL, withIntermediateDirectories: true)
+
+ try setModificationDate(Date(timeIntervalSince1970: 100), for: oldAppURL)
+ try setModificationDate(Date(timeIntervalSince1970: 200), for: newAppURL)
+ try setModificationDate(Date(timeIntervalSince1970: 300), for: otherAppURL)
+
+ let locatedAppURL = UpdateQuarantineRepair.locateExtractedApplication(
+ bundleIdentifier: "com.cmuxterm.app.nightly",
+ bundleName: "cmux NIGHTLY.app",
+ cachesDirectory: cachesDirectory
+ )
+
+ XCTAssertEqual(locatedAppURL, newAppURL)
+ }
+
+ private func makeTemporaryDirectory(named name: String) throws -> URL {
+ let directoryURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent("UpdateQuarantineRepairTests", isDirectory: true)
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ .appendingPathComponent(name, isDirectory: true)
+ try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true)
+ return directoryURL
+ }
+
+ private func makeTemporaryFile(named name: String) throws -> URL {
+ let directoryURL = try makeTemporaryDirectory(named: "Files")
+ let fileURL = directoryURL.appendingPathComponent(name)
+ try createFile(at: fileURL)
+ return fileURL
+ }
+
+ private func createFile(at url: URL) throws {
+ try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
+ XCTAssertTrue(FileManager.default.createFile(atPath: url.path, contents: Data()))
+ }
+
+ private func setModificationDate(_ modificationDate: Date, for url: URL) throws {
+ try FileManager.default.setAttributes([.modificationDate: modificationDate], ofItemAtPath: url.path)
+ }
+
+ private func writeRawQuarantine(_ value: String, to url: URL) throws {
+ let bytes = Array(value.utf8)
+ let status = url.path.withCString { pathPointer in
+ "com.apple.quarantine".withCString { attributePointer in
+ bytes.withUnsafeBytes { bufferPointer in
+ setxattr(pathPointer, attributePointer, bufferPointer.baseAddress, bytes.count, 0, 0)
+ }
+ }
+ }
+ XCTAssertEqual(status, 0)
+ }
+}
From 798c1fbc42f830bffbdb96f378272dd7b225080b Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Wed, 18 Mar 2026 01:28:11 -0700
Subject: [PATCH 08/24] Fix laggy terminal sync during sidebar drags (#1598)
* Fix sidebar drag terminal resize lag
* Add display resolution churn regression
* Prelaunch display churn helper in e2e workflow
* Use manifest handoff for display churn UI test
* Fix e2e display churn harness startup
* Resolve display churn UI test socket path
* Use marker-based socket discovery in display UI test
* Add failing sidebar drag portal regression tests
* Fix sidebar drag terminal portal resize lag
* Add failing scoped resize regression tests
* Fix terminal portal resize scheduling lag
* Add failing zsh resize prompt regression test
* Fix zsh resize prompt duplication
* Fix Sequoia sidebar resize regression
* Guard display-resolution CI runner
* Run display-resolution CI on WarpBuild
* Allow backgrounded display regression app launch
* Launch display regression app directly
* Launch display regression app via NSWorkspace
* Load display regression launch env from manifest
* Write display regression manifest in runner temp dir
* Write display regression manifest in shared tmp
* Write display regression manifest in repo scratch dir
* Launch display regression app with explicit env
* Avoid xcodebuild broken pipe in compat CI
* Launch display regression via XCUIApplication
* Harden display regression socket readiness
* Trust display socket diagnostics path
* Replace display socket probe with render diagnostics
* Write display churn start marker atomically
* Move display churn harness out of /tmp
---------
Co-authored-by: Lawrence Chen
---
.github/workflows/ci-macos-compat.yml | 5 +-
.github/workflows/ci.yml | 134 ++++++
.github/workflows/test-e2e.yml | 61 ++-
GhosttyTabs.xcodeproj/project.pbxproj | 4 +
.../cmux-zsh-integration.zsh | 5 +-
Sources/AppDelegate.swift | 240 +++++++++++
Sources/ContentView.swift | 27 +-
Sources/GhosttyTerminalView.swift | 21 +-
Sources/TerminalWindowPortal.swift | 73 +++-
Sources/cmuxApp.swift | 39 ++
cmuxTests/GhosttyConfigTests.swift | 51 +++
cmuxTests/TerminalAndGhosttyTests.swift | 321 ++++++++++++++-
.../DisplayResolutionRegressionUITests.swift | 386 ++++++++++++++++++
scripts/create-virtual-display.m | 196 ++++++++-
tests/test_ci_self_hosted_guard.sh | 13 +
vendor/bonsplit | 2 +-
16 files changed, 1537 insertions(+), 41 deletions(-)
create mode 100644 cmuxUITests/DisplayResolutionRegressionUITests.swift
diff --git a/.github/workflows/ci-macos-compat.yml b/.github/workflows/ci-macos-compat.yml
index 12e827b1..c3e9b358 100644
--- a/.github/workflows/ci-macos-compat.yml
+++ b/.github/workflows/ci-macos-compat.yml
@@ -48,9 +48,10 @@ jobs:
echo "Selected: $XCODE_APP"
echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV"
export DEVELOPER_DIR="$XCODE_DIR"
- XCODE_VER="$(xcodebuild -version | head -1)"
+ XCODE_VERSION_OUTPUT="$(xcodebuild -version)"
+ XCODE_VER="${XCODE_VERSION_OUTPUT%%$'\n'*}"
echo "XCODE_VER=$XCODE_VER" >> "$GITHUB_ENV"
- echo "$XCODE_VER"
+ echo "$XCODE_VERSION_OUTPUT"
xcrun --sdk macosx --show-sdk-path
sw_vers
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2d24e983..e93274d3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -385,3 +385,137 @@ jobs:
CMUX_LAG_MAX_CHURN_P95_MS=35 \
CMUX_LAG_KEY_EVENTS=180 \
python3 tests/test_workspace_churn_up_arrow_lag.py
+
+ ui-display-resolution-regression:
+ if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
+ runs-on: warp-macos-15-arm64-6x
+ timeout-minutes: 25
+ steps:
+ - name: Checkout
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ with:
+ submodules: recursive
+
+ - name: Select Xcode
+ run: |
+ set -euo pipefail
+ if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then
+ XCODE_DIR="/Applications/Xcode.app/Contents/Developer"
+ else
+ XCODE_APP="$(ls -d /Applications/Xcode*.app 2>/dev/null | sort | tail -n 1 || true)"
+ if [ -n "$XCODE_APP" ]; then
+ XCODE_DIR="$XCODE_APP/Contents/Developer"
+ else
+ echo "No Xcode.app found under /Applications" >&2
+ exit 1
+ fi
+ fi
+ echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV"
+ export DEVELOPER_DIR="$XCODE_DIR"
+ xcodebuild -version
+ xcrun --sdk macosx --show-sdk-path
+
+ - name: Download pre-built GhosttyKit.xcframework
+ run: ./scripts/download-prebuilt-ghosttykit.sh
+
+ - name: Install zig
+ run: |
+ ZIG_REQUIRED="0.15.2"
+ if command -v zig >/dev/null 2>&1 && zig version 2>/dev/null | grep -q "^${ZIG_REQUIRED}"; then
+ echo "zig ${ZIG_REQUIRED} already installed"
+ else
+ echo "Installing zig ${ZIG_REQUIRED} from tarball"
+ curl -fSL "https://ziglang.org/download/${ZIG_REQUIRED}/zig-aarch64-macos-${ZIG_REQUIRED}.tar.xz" -o /tmp/zig.tar.xz
+ tar xf /tmp/zig.tar.xz -C /tmp
+ sudo mkdir -p /usr/local/bin /usr/local/lib
+ sudo cp -f /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/zig /usr/local/bin/zig
+ sudo cp -rf /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/lib /usr/local/lib/zig
+ export PATH="/usr/local/bin:$PATH"
+ zig version
+ fi
+
+ - name: Cache Swift packages
+ uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
+ with:
+ path: .ci-source-packages
+ key: spm-ui-display-resolution-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
+ restore-keys: spm-ui-display-resolution-
+
+ - name: Resolve Swift packages
+ run: |
+ set -euo pipefail
+ SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
+ mkdir -p "$SOURCE_PACKAGES_DIR"
+
+ for attempt in 1 2 3; do
+ if xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \
+ -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
+ -resolvePackageDependencies; then
+ exit 0
+ fi
+ if [ "$attempt" -eq 3 ]; then
+ echo "Failed to resolve Swift packages after 3 attempts" >&2
+ exit 1
+ fi
+ echo "Package resolution failed on attempt $attempt, retrying..."
+ sleep $((attempt * 5))
+ done
+
+ - name: Run display resolution churn UI regression
+ run: |
+ set -euo pipefail
+ SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
+ HARNESS_DIR="${RUNNER_TEMP}/cmux-display-churn-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
+ mkdir -p "$HARNESS_DIR"
+ PREFIX="${HARNESS_DIR}/cmux-display-churn"
+ READY_PATH="${PREFIX}.ready"
+ DISPLAY_ID_PATH="${PREFIX}.id"
+ START_PATH="${PREFIX}.start"
+ DONE_PATH="${PREFIX}.done"
+ LOG_PATH="${PREFIX}.log"
+ MANIFEST_PATH="${HARNESS_DIR}/cmux-ui-test-display-harness.json"
+
+ rm -f "$READY_PATH" "$DISPLAY_ID_PATH" "$START_PATH" "$DONE_PATH" "$LOG_PATH" "$MANIFEST_PATH"
+
+ clang -framework Foundation -framework CoreGraphics \
+ -o /tmp/create-virtual-display scripts/create-virtual-display.m
+
+ /tmp/create-virtual-display \
+ --modes 1920x1080,1728x1117,1600x900,1440x810 \
+ --ready-path "$READY_PATH" \
+ --display-id-path "$DISPLAY_ID_PATH" \
+ --start-path "$START_PATH" \
+ --done-path "$DONE_PATH" \
+ --iterations 40 \
+ --interval-ms 40 \
+ >"$LOG_PATH" 2>&1 &
+ VDISPLAY_PID=$!
+ trap 'kill "$VDISPLAY_PID" >/dev/null 2>&1 || true; rm -f "$MANIFEST_PATH"' EXIT
+
+ for _ in {1..120}; do
+ [ -f "$READY_PATH" ] && break
+ sleep 0.25
+ done
+ [ -f "$READY_PATH" ] || {
+ echo "Display harness failed to start" >&2
+ cat "$LOG_PATH" >&2 || true
+ exit 1
+ }
+
+ cat >"$MANIFEST_PATH" <"$LOG_PATH" 2>&1 &
+ DISPLAY_VDISPLAY_PID=$!
+ trap 'kill "${DISPLAY_VDISPLAY_PID:-}" >/dev/null 2>&1 || true; rm -f "$MANIFEST_PATH"' EXIT
+
+ for _ in {1..120}; do
+ [ -f "$READY_PATH" ] && break
+ sleep 0.25
+ done
+ [ -f "$READY_PATH" ] || {
+ echo "Display harness failed to start" >&2
+ cat "$LOG_PATH" >&2 || true
+ exit 1
+ }
+
+ cat >"$MANIFEST_PATH" <&1 || true )
echo "Available devices:"
@@ -233,7 +290,7 @@ jobs:
fi
set +e
- OUTPUT=$(xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \
+ OUTPUT=$(env "${DISPLAY_ENV_PREFIX[@]}" xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-disableAutomaticPackageResolution \
-destination "platform=macOS" \
diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj
index 90ef397b..dac5549c 100644
--- a/GhosttyTabs.xcodeproj/project.pbxproj
+++ b/GhosttyTabs.xcodeproj/project.pbxproj
@@ -76,6 +76,7 @@
B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */; };
B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */; };
B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */; };
+ B8F266266A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F266276A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift */; };
C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */; };
B9000014A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000013A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift */; };
B9000015A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */; };
@@ -233,6 +234,7 @@
A5001611 /* SessionPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistence.swift; sourceTree = ""; };
818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = ""; };
B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = ""; };
+ B8F266276A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayResolutionRegressionUITests.swift; sourceTree = ""; };
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = ""; };
A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
IC000002 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder; path = AppIcon.icon; sourceTree = ""; };
@@ -496,6 +498,7 @@
B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */,
818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */,
B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */,
+ B8F266276A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift */,
D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */,
D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */,
FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */,
@@ -756,6 +759,7 @@
B9000015A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift in Sources */,
B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */,
B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */,
+ B8F266266A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift in Sources */,
D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */,
D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */,
FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */,
diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh
index 3026ae95..6077a528 100644
--- a/Resources/shell-integration/cmux-zsh-integration.zsh
+++ b/Resources/shell-integration/cmux-zsh-integration.zsh
@@ -143,9 +143,8 @@ _cmux_install_winch_guard() {
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
- # Keep a spacer line so prompt redraw during resize cannot clobber the
- # tail of command output that was rendered immediately above the prompt.
- builtin print -r -- ""
+ # Ghostty already marks prompt redraws on SIGWINCH. Writing to the PTY
+ # here grows the screen and makes resize look like a fresh prompt.
return 0
}
diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift
index 4e559abc..b0e05526 100644
--- a/Sources/AppDelegate.swift
+++ b/Sources/AppDelegate.swift
@@ -2043,6 +2043,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
private var didSetupGotoSplitUITest = false
private var gotoSplitUITestObservers: [NSObjectProtocol] = []
private var didSetupMultiWindowNotificationsUITest = false
+ private var didSetupDisplayResolutionUITestDiagnostics = false
+ private var displayResolutionUITestObservers: [NSObjectProtocol] = []
+ private struct UITestRenderDiagnosticsSnapshot {
+ let panelId: UUID
+ let drawCount: Int
+ let presentCount: Int
+ let lastPresentTime: Double
+ let windowVisible: Bool
+ let appIsActive: Bool
+ let desiredFocus: Bool
+ let isFirstResponder: Bool
+ }
var debugCloseMainWindowConfirmationHandler: ((NSWindow) -> Bool)?
// Keep debug-only windows alive when tests intentionally inject key mismatches.
private var debugDetachedContextWindows: [NSWindow] = []
@@ -2343,6 +2355,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
if NSApp.windows.isEmpty {
self.openNewMainWindow(nil)
}
+ self.moveUITestWindowToTargetDisplayIfNeeded()
NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
self.writeUITestDiagnosticsIfNeeded(stage: "afterForceWindow")
}
@@ -2380,6 +2393,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
let windows = NSApp.windows
let ids = windows.map { $0.identifier?.rawValue ?? "" }.joined(separator: ",")
let vis = windows.map { $0.isVisible ? "1" : "0" }.joined(separator: ",")
+ let screenIDs = windows.map { $0.screen?.cmuxDisplayID.map(String.init) ?? "" }.joined(separator: ",")
+ let targetDisplayID = env["CMUX_UI_TEST_TARGET_DISPLAY_ID"] ?? ""
payload["stage"] = stage
payload["pid"] = String(ProcessInfo.processInfo.processIdentifier)
@@ -2388,6 +2403,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
payload["windowsCount"] = String(windows.count)
payload["windowIdentifiers"] = ids
payload["windowVisibleFlags"] = vis
+ payload["windowScreenDisplayIDs"] = screenIDs
+ payload["uiTestTargetDisplayID"] = targetDisplayID
+ if let rawDisplayID = UInt32(targetDisplayID) {
+ let screenPresent = NSScreen.screens.contains(where: { $0.cmuxDisplayID == rawDisplayID })
+ let movedWindow = windows.contains(where: { $0.screen?.cmuxDisplayID == rawDisplayID })
+ payload["targetDisplayPresent"] = screenPresent ? "1" : "0"
+ payload["targetDisplayMoveSucceeded"] = movedWindow ? "1" : "0"
+ }
+ appendUITestRenderDiagnosticsIfNeeded(&payload, environment: env)
+ appendUITestSocketDiagnosticsIfNeeded(&payload, environment: env)
guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return }
try? data.write(to: URL(fileURLWithPath: path), options: .atomic)
@@ -2400,6 +2425,160 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
return object
}
+
+ private func appendUITestSocketDiagnosticsIfNeeded(
+ _ payload: inout [String: String],
+ environment env: [String: String]
+ ) {
+ guard env["CMUX_UI_TEST_SOCKET_SANITY"] == "1" else { return }
+
+ guard let config = socketListenerConfigurationIfEnabled() else {
+ payload["socketExpectedPath"] = env["CMUX_SOCKET_PATH"] ?? ""
+ payload["socketMode"] = "off"
+ payload["socketReady"] = "0"
+ payload["socketPingResponse"] = ""
+ payload["socketIsRunning"] = "0"
+ payload["socketAcceptLoopAlive"] = "0"
+ payload["socketPathMatches"] = "0"
+ payload["socketPathExists"] = "0"
+ payload["socketFailureSignals"] = "socket_disabled"
+ return
+ }
+
+ let socketPath = TerminalController.shared.activeSocketPath(preferredPath: config.path)
+ let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: socketPath)
+ let pingResponse = health.isHealthy
+ ? TerminalController.probeSocketCommand("ping", at: socketPath, timeout: 1.0)
+ : nil
+ let isReady = health.isHealthy && pingResponse == "PONG"
+ var failureSignals = health.failureSignals
+ if health.isHealthy && pingResponse != "PONG" {
+ failureSignals.append("ping_timeout")
+ }
+
+ payload["socketExpectedPath"] = socketPath
+ payload["socketMode"] = config.mode.rawValue
+ payload["socketReady"] = isReady ? "1" : "0"
+ payload["socketPingResponse"] = pingResponse ?? ""
+ payload["socketIsRunning"] = health.isRunning ? "1" : "0"
+ payload["socketAcceptLoopAlive"] = health.acceptLoopAlive ? "1" : "0"
+ payload["socketPathMatches"] = health.socketPathMatches ? "1" : "0"
+ payload["socketPathExists"] = health.socketPathExists ? "1" : "0"
+ payload["socketFailureSignals"] = failureSignals.joined(separator: ",")
+ }
+
+ private func appendUITestRenderDiagnosticsIfNeeded(
+ _ payload: inout [String: String],
+ environment env: [String: String]
+ ) {
+ guard env["CMUX_UI_TEST_DISPLAY_RENDER_STATS"] == "1" else { return }
+
+ guard let renderState = currentUITestRenderDiagnostics() else {
+ payload["renderStatsAvailable"] = "0"
+ payload["renderPanelId"] = ""
+ payload["renderDrawCount"] = ""
+ payload["renderPresentCount"] = ""
+ payload["renderLastPresentTime"] = ""
+ payload["renderWindowVisible"] = ""
+ payload["renderAppIsActive"] = ""
+ payload["renderDesiredFocus"] = ""
+ payload["renderIsFirstResponder"] = ""
+ payload["renderDiagnosticsUpdatedAt"] = String(format: "%.6f", ProcessInfo.processInfo.systemUptime)
+ return
+ }
+
+ payload["renderStatsAvailable"] = "1"
+ payload["renderPanelId"] = renderState.panelId.uuidString
+ payload["renderDrawCount"] = String(renderState.drawCount)
+ payload["renderPresentCount"] = String(renderState.presentCount)
+ payload["renderLastPresentTime"] = String(format: "%.6f", renderState.lastPresentTime)
+ payload["renderWindowVisible"] = renderState.windowVisible ? "1" : "0"
+ payload["renderAppIsActive"] = renderState.appIsActive ? "1" : "0"
+ payload["renderDesiredFocus"] = renderState.desiredFocus ? "1" : "0"
+ payload["renderIsFirstResponder"] = renderState.isFirstResponder ? "1" : "0"
+ payload["renderDiagnosticsUpdatedAt"] = String(format: "%.6f", ProcessInfo.processInfo.systemUptime)
+ }
+
+ private func currentUITestRenderDiagnostics() -> UITestRenderDiagnosticsSnapshot? {
+ guard let tabManager,
+ let tabId = tabManager.selectedTabId,
+ let workspace = tabManager.tabs.first(where: { $0.id == tabId }) else {
+ return nil
+ }
+
+ let terminalPanel: TerminalPanel? = {
+ if let focusedPanelId = workspace.focusedPanelId,
+ let terminalPanel = workspace.terminalPanel(for: focusedPanelId) {
+ return terminalPanel
+ }
+ if let focusedTerminalPanel = workspace.focusedTerminalPanel {
+ return focusedTerminalPanel
+ }
+ return workspace.panels.values.compactMap { $0 as? TerminalPanel }.first
+ }()
+
+ guard let terminalPanel else { return nil }
+ let stats = terminalPanel.hostedView.debugRenderStats()
+ return UITestRenderDiagnosticsSnapshot(
+ panelId: terminalPanel.id,
+ drawCount: stats.drawCount,
+ presentCount: stats.presentCount,
+ lastPresentTime: stats.lastPresentTime,
+ windowVisible: stats.windowOcclusionVisible,
+ appIsActive: stats.appIsActive,
+ desiredFocus: stats.desiredFocus,
+ isFirstResponder: stats.isFirstResponder
+ )
+ }
+
+ private func moveUITestWindowToTargetDisplayIfNeeded(attempt: Int = 0) {
+ let env = ProcessInfo.processInfo.environment
+ guard let rawDisplayID = env["CMUX_UI_TEST_TARGET_DISPLAY_ID"],
+ let targetDisplayID = UInt32(rawDisplayID) else {
+ return
+ }
+
+ guard let screen = NSScreen.screens.first(where: { $0.cmuxDisplayID == targetDisplayID }) else {
+ if attempt < 20 {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
+ self?.moveUITestWindowToTargetDisplayIfNeeded(attempt: attempt + 1)
+ }
+ }
+ self.writeUITestDiagnosticsIfNeeded(stage: "targetDisplayMissing")
+ return
+ }
+
+ guard let window = NSApp.windows.first else {
+ if attempt < 20 {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
+ self?.moveUITestWindowToTargetDisplayIfNeeded(attempt: attempt + 1)
+ }
+ }
+ self.writeUITestDiagnosticsIfNeeded(stage: "targetDisplayNoWindow")
+ return
+ }
+
+ let visibleFrame = screen.visibleFrame
+ let width = min(window.frame.width, max(visibleFrame.width - 80, 480))
+ let height = min(window.frame.height, max(visibleFrame.height - 80, 360))
+ let frame = NSRect(
+ x: visibleFrame.midX - (width / 2),
+ y: visibleFrame.midY - (height / 2),
+ width: width,
+ height: height
+ ).integral
+
+ window.setFrame(frame, display: true, animate: false)
+ window.makeKeyAndOrderFront(nil)
+ window.orderFrontRegardless()
+ if window.screen?.cmuxDisplayID != targetDisplayID, attempt < 20 {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
+ self?.moveUITestWindowToTargetDisplayIfNeeded(attempt: attempt + 1)
+ }
+ return
+ }
+ self.writeUITestDiagnosticsIfNeeded(stage: "afterMoveToTargetDisplay")
+ }
#endif
func applicationDidBecomeActive(_ notification: Notification) {
@@ -2466,6 +2645,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
setupJumpUnreadUITestIfNeeded()
setupGotoSplitUITestIfNeeded()
setupMultiWindowNotificationsUITestIfNeeded()
+ setupDisplayResolutionUITestDiagnosticsIfNeeded()
// UI tests sometimes don't run SwiftUI `.onAppear` soon enough (or at all) on the VM.
// The automation socket is a core testing primitive, so ensure it's started here when
@@ -2482,11 +2662,71 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
socketPath: SocketControlSettings.socketPath(),
accessMode: mode
)
+ scheduleUITestSocketSanityCheckIfNeeded()
}
}
#endif
}
+#if DEBUG
+ private func scheduleUITestSocketSanityCheckIfNeeded() {
+ let env = ProcessInfo.processInfo.environment
+ guard env["CMUX_UI_TEST_SOCKET_SANITY"] == "1" else { return }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) { [weak self] in
+ guard let self else { return }
+ guard let config = self.socketListenerConfigurationIfEnabled() else {
+ self.writeUITestDiagnosticsIfNeeded(stage: "socketSanityDisabled")
+ return
+ }
+
+ let expectedPath = TerminalController.shared.activeSocketPath(preferredPath: config.path)
+ let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: expectedPath)
+ let pingResponse = health.isHealthy
+ ? TerminalController.probeSocketCommand("ping", at: expectedPath, timeout: 1.0)
+ : nil
+ let isReady = health.isHealthy && pingResponse == "PONG"
+ if isReady {
+ self.writeUITestDiagnosticsIfNeeded(stage: "socketSanityReady")
+ return
+ }
+
+ self.writeUITestDiagnosticsIfNeeded(stage: "socketSanityRestart")
+ self.restartSocketListenerIfEnabled(source: "uiTest.socketSanity")
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) { [weak self] in
+ self?.writeUITestDiagnosticsIfNeeded(stage: "socketSanityPostRestart")
+ }
+ }
+ }
+
+ private func setupDisplayResolutionUITestDiagnosticsIfNeeded() {
+ let env = ProcessInfo.processInfo.environment
+ guard env["CMUX_UI_TEST_DISPLAY_RENDER_STATS"] == "1" else { return }
+ guard !didSetupDisplayResolutionUITestDiagnostics else { return }
+ didSetupDisplayResolutionUITestDiagnostics = true
+
+ let center = NotificationCenter.default
+ let observe: (Notification.Name, String) -> Void = { [weak self] name, stage in
+ guard let self else { return }
+ let observer = center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
+ Task { @MainActor [weak self] in
+ self?.writeUITestDiagnosticsIfNeeded(stage: stage)
+ }
+ }
+ self.displayResolutionUITestObservers.append(observer)
+ }
+
+ observe(NSWindow.didResizeNotification, "displayUITest.windowDidResize")
+ observe(NSWindow.didMoveNotification, "displayUITest.windowDidMove")
+ observe(NSWindow.didChangeScreenNotification, "displayUITest.windowDidChangeScreen")
+ observe(NSWindow.didChangeBackingPropertiesNotification, "displayUITest.windowDidChangeBacking")
+ observe(.terminalSurfaceDidBecomeReady, "displayUITest.terminalSurfaceDidBecomeReady")
+ observe(.terminalPortalVisibilityDidChange, "displayUITest.terminalPortalVisibilityDidChange")
+
+ writeUITestDiagnosticsIfNeeded(stage: "displayUITest.setup")
+ }
+#endif
+
private func prepareStartupSessionSnapshotIfNeeded() {
guard !didPrepareStartupSessionSnapshot else { return }
didPrepareStartupSessionSnapshot = true
diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift
index fca38cd5..4d195ac2 100644
--- a/Sources/ContentView.swift
+++ b/Sources/ContentView.swift
@@ -1914,7 +1914,10 @@ struct ContentView: View {
}
.onDisappear {
hoveredResizerHandles.remove(handle)
- isResizerDragging = false
+ if isResizerDragging {
+ TerminalWindowPortalRegistry.endInteractiveGeometryResize()
+ isResizerDragging = false
+ }
sidebarDragStartWidth = nil
isResizerBandActive = false
scheduleSidebarResizerCursorRelease(force: true)
@@ -1923,11 +1926,9 @@ struct ContentView: View {
DragGesture(minimumDistance: 0, coordinateSpace: .global)
.onChanged { value in
if !isResizerDragging {
+ TerminalWindowPortalRegistry.beginInteractiveGeometryResize()
isResizerDragging = true
sidebarDragStartWidth = sidebarWidth
- #if DEBUG
- dlog("sidebar.resizeDragStart")
- #endif
}
activateSidebarResizerCursor()
@@ -1942,6 +1943,7 @@ struct ContentView: View {
}
.onEnded { _ in
if isResizerDragging {
+ TerminalWindowPortalRegistry.endInteractiveGeometryResize()
isResizerDragging = false
sidebarDragStartWidth = nil
}
@@ -2712,12 +2714,20 @@ struct ContentView: View {
}
// Sidebar width changes are pure SwiftUI layout updates, so portal-hosted
// terminals need an explicit post-layout geometry resync.
- TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows()
+ if let observedWindow {
+ TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: observedWindow)
+ } else {
+ TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows()
+ }
updateSidebarResizerBandState()
})
view = AnyView(view.onChange(of: sidebarState.isVisible) { _ in
- TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows()
+ if let observedWindow {
+ TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: observedWindow)
+ } else {
+ TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows()
+ }
updateSidebarResizerBandState()
})
@@ -2739,6 +2749,11 @@ struct ContentView: View {
})
view = AnyView(view.onDisappear {
+ if isResizerDragging {
+ TerminalWindowPortalRegistry.endInteractiveGeometryResize()
+ isResizerDragging = false
+ sidebarDragStartWidth = nil
+ }
removeSidebarResizerPointerMonitor()
})
diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift
index e6d16ea2..ded6ac00 100644
--- a/Sources/GhosttyTerminalView.swift
+++ b/Sources/GhosttyTerminalView.swift
@@ -6611,6 +6611,9 @@ final class GhosttySurfaceScrollView: NSView {
if let overlay = searchOverlayHostingView {
_ = setFrameIfNeeded(overlay, to: bounds)
}
+ // NSScrollView can defer clip-view/content-size updates until its own layout pass,
+ // which makes interactive width changes arrive a queue turn late on Sequoia.
+ scrollView.layoutSubtreeIfNeeded()
updateNotificationRingPath()
updateFlashPath(style: .standardFocus)
synchronizeScrollView()
@@ -8834,6 +8837,14 @@ struct GhosttyTerminalView: NSViewRepresentable {
return !hostedViewHasSuperview
}
+ static func shouldSynchronizePortalGeometryImmediately(
+ hostInLiveResize: Bool,
+ windowInLiveResize: Bool,
+ interactiveGeometryResizeActive: Bool
+ ) -> Bool {
+ hostInLiveResize || windowInLiveResize || interactiveGeometryResizeActive
+ }
+
private static func synchronizePortalGeometry(
for host: HostContainerView,
coordinator: Coordinator
@@ -8841,14 +8852,20 @@ struct GhosttyTerminalView: NSViewRepresentable {
let geometryRevision = host.geometryRevision
guard coordinator.lastSynchronizedHostGeometryRevision != geometryRevision else { return }
coordinator.lastSynchronizedHostGeometryRevision = geometryRevision
- if host.inLiveResize || host.window?.inLiveResize == true {
+ let window = host.window
+ if shouldSynchronizePortalGeometryImmediately(
+ hostInLiveResize: host.inLiveResize,
+ windowInLiveResize: window?.inLiveResize == true,
+ interactiveGeometryResizeActive: TerminalWindowPortalRegistry.isInteractiveGeometryResizeActive
+ ) {
TerminalWindowPortalRegistry.synchronizeForAnchor(host)
return
}
// Avoid synchronizing the terminal portal while AppKit is still inside
// the current layout turn. Re-entrant syncs here can wedge window resize
// handling and leave the app spinning on the wait cursor.
- TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows()
+ guard let window else { return }
+ TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: window)
}
func makeNSView(context: Context) -> NSView {
diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift
index e4b78917..0518e37c 100644
--- a/Sources/TerminalWindowPortal.swift
+++ b/Sources/TerminalWindowPortal.swift
@@ -567,6 +567,9 @@ private final class SplitDividerOverlayView: NSView {
@MainActor
final class WindowTerminalPortal: NSObject {
+#if DEBUG
+ static var isPointerDragActiveForTesting = false
+#endif
private static let tinyHideThreshold: CGFloat = 1
private static let minimumRevealWidth: CGFloat = 24
private static let minimumRevealHeight: CGFloat = 18
@@ -677,10 +680,11 @@ final class WindowTerminalPortal: NSObject {
geometryObservers.removeAll()
}
- private func scheduleExternalGeometrySynchronize() {
+ fileprivate func scheduleExternalGeometrySynchronize() {
guard !hasExternalGeometrySyncScheduled else { return }
hasExternalGeometrySyncScheduled = true
- let requiresSettledLayout = !(hostView.inLiveResize || window?.inLiveResize == true)
+ let isDragEvent = TerminalWindowPortalRegistry.isInteractiveGeometryResizeActive
+ let requiresSettledLayout = !(hostView.inLiveResize || window?.inLiveResize == true || isDragEvent)
DispatchQueue.main.async { [weak self] in
guard let self else { return }
let performSync = {
@@ -1427,22 +1431,23 @@ final class WindowTerminalPortal: NSObject {
#endif
}
- if hasFiniteFrame && !Self.rectApproximatelyEqual(oldFrame, targetFrame) {
- CATransaction.begin()
- CATransaction.setDisableActions(true)
- hostedView.frame = targetFrame
- CATransaction.commit()
- hostedView.reconcileGeometryNow()
- hostedView.refreshSurfaceNow(reason: "portal.frameChange")
- }
-
if hasFiniteFrame {
let expectedBounds = NSRect(origin: .zero, size: targetFrame.size)
+ var geometryChanged = false
+ CATransaction.begin()
+ CATransaction.setDisableActions(true)
+ if !Self.rectApproximatelyEqual(oldFrame, targetFrame) {
+ hostedView.frame = targetFrame
+ geometryChanged = true
+ }
if !Self.rectApproximatelyEqual(hostedView.bounds, expectedBounds) {
- CATransaction.begin()
- CATransaction.setDisableActions(true)
hostedView.bounds = expectedBounds
- CATransaction.commit()
+ geometryChanged = true
+ }
+ CATransaction.commit()
+ if geometryChanged {
+ hostedView.reconcileGeometryNow()
+ hostedView.refreshSurfaceNow(reason: "portal.frameChange")
}
}
@@ -1641,14 +1646,25 @@ final class WindowTerminalPortal: NSObject {
@MainActor
enum TerminalWindowPortalRegistry {
+#if DEBUG
+ static var isPointerDragActiveForTesting = false
+#endif
private static var portalsByWindowId: [ObjectIdentifier: WindowTerminalPortal] = [:]
private static var hostedToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:]
private static var hasPendingExternalGeometrySyncForAllWindows = false
+ private static var interactiveGeometryResizeCount = 0
#if DEBUG
private static var blockedBindCount: Int = 0
private static var blockedBindReasons: [String: Int] = [:]
#endif
+ static var isInteractiveGeometryResizeActive: Bool {
+#if DEBUG
+ if Self.isPointerDragActiveForTesting { return true }
+#endif
+ return Self.interactiveGeometryResizeCount > 0
+ }
+
private static func bindBlockReason(
expectedSurfaceId: UUID?,
expectedGeneration: UInt64?,
@@ -1731,6 +1747,15 @@ enum TerminalWindowPortalRegistry {
return portal
}
+ private static func existingPortal(for window: NSWindow) -> WindowTerminalPortal? {
+ if let existing = objc_getAssociatedObject(window, &cmuxWindowTerminalPortalKey) as? WindowTerminalPortal {
+ portalsByWindowId[ObjectIdentifier(window)] = existing
+ installWindowCloseObserverIfNeeded(for: window)
+ return existing
+ }
+ return portalsByWindowId[ObjectIdentifier(window)]
+ }
+
static func bind(
hostedView: GhosttySurfaceScrollView,
to anchorView: NSView,
@@ -1789,16 +1814,34 @@ enum TerminalWindowPortalRegistry {
portal.synchronizeHostedViewForAnchor(anchorView)
}
+ static func scheduleExternalGeometrySynchronize(for window: NSWindow) {
+ existingPortal(for: window)?.scheduleExternalGeometrySynchronize()
+ }
+
+ static func beginInteractiveGeometryResize() {
+ interactiveGeometryResizeCount += 1
+ }
+
+ static func endInteractiveGeometryResize() {
+ interactiveGeometryResizeCount = max(0, interactiveGeometryResizeCount - 1)
+ }
+
static func scheduleExternalGeometrySynchronizeForAllWindows() {
guard !Self.hasPendingExternalGeometrySyncForAllWindows else { return }
Self.hasPendingExternalGeometrySyncForAllWindows = true
+ let isDragEvent = Self.isInteractiveGeometryResizeActive
DispatchQueue.main.async {
- DispatchQueue.main.async {
+ let performSync = {
Self.hasPendingExternalGeometrySyncForAllWindows = false
for portal in Self.portalsByWindowId.values {
portal.synchronizeAllEntriesFromExternalGeometryChange()
}
}
+ if isDragEvent {
+ performSync()
+ } else {
+ DispatchQueue.main.async(execute: performSync)
+ }
}
}
diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift
index ca6f5e78..b3be866c 100644
--- a/Sources/cmuxApp.swift
+++ b/Sources/cmuxApp.swift
@@ -4,6 +4,43 @@ import Darwin
import Bonsplit
import UniformTypeIdentifiers
+enum UITestLaunchManifest {
+ static let argumentName = "-cmuxUITestLaunchManifest"
+
+ struct Payload: Decodable {
+ let environment: [String: String]
+ }
+
+ static func applyIfPresent(
+ arguments: [String] = CommandLine.arguments,
+ loadData: (String) -> Data? = { path in
+ try? Data(contentsOf: URL(fileURLWithPath: path))
+ },
+ applyEnvironment: (String, String) -> Void = { key, value in
+ setenv(key, value, 1)
+ }
+ ) {
+ guard let path = manifestPath(from: arguments),
+ let data = loadData(path),
+ let payload = try? JSONDecoder().decode(Payload.self, from: data) else {
+ return
+ }
+
+ for (key, value) in payload.environment {
+ applyEnvironment(key, value)
+ }
+ }
+
+ static func manifestPath(from arguments: [String]) -> String? {
+ guard let index = arguments.firstIndex(of: argumentName) else { return nil }
+ let valueIndex = arguments.index(after: index)
+ guard valueIndex < arguments.endIndex else { return nil }
+
+ let rawPath = arguments[valueIndex].trimmingCharacters(in: .whitespacesAndNewlines)
+ return rawPath.isEmpty ? nil : rawPath
+ }
+}
+
@main
struct cmuxApp: App {
@StateObject private var tabManager: TabManager
@@ -45,6 +82,8 @@ struct cmuxApp: App {
}
init() {
+ UITestLaunchManifest.applyIfPresent()
+
if SocketControlSettings.shouldBlockUntaggedDebugLaunch() {
Self.terminateForMissingLaunchTag()
}
diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift
index 5cca92b3..f2cc880e 100644
--- a/cmuxTests/GhosttyConfigTests.swift
+++ b/cmuxTests/GhosttyConfigTests.swift
@@ -1793,6 +1793,43 @@ final class SocketControlSettingsTests: XCTestCase {
}
}
+final class UITestLaunchManifestTests: XCTestCase {
+ func testManifestPathReadsArgumentValue() {
+ XCTAssertEqual(
+ UITestLaunchManifest.manifestPath(
+ from: ["cmux", "-cmuxUITestLaunchManifest", "/tmp/cmux-ui-test-launch.json"]
+ ),
+ "/tmp/cmux-ui-test-launch.json"
+ )
+ }
+
+ func testManifestPathReturnsNilWithoutValue() {
+ XCTAssertNil(
+ UITestLaunchManifest.manifestPath(
+ from: ["cmux", "-cmuxUITestLaunchManifest"]
+ )
+ )
+ }
+
+ func testApplyIfPresentDecodesEnvironmentPayload() {
+ let payload = """
+ {"environment":{"CMUX_TAG":"ui-tests-display","CMUX_SOCKET_PATH":"/tmp/cmux-ui-tests.sock"}}
+ """.data(using: .utf8)!
+ var applied: [String: String] = [:]
+
+ UITestLaunchManifest.applyIfPresent(
+ arguments: ["cmux", UITestLaunchManifest.argumentName, "/tmp/cmux-ui-test-launch.json"],
+ loadData: { _ in payload },
+ applyEnvironment: { key, value in
+ applied[key] = value
+ }
+ )
+
+ XCTAssertEqual(applied["CMUX_TAG"], "ui-tests-display")
+ XCTAssertEqual(applied["CMUX_SOCKET_PATH"], "/tmp/cmux-ui-tests.sock")
+ }
+}
+
final class PostHogAnalyticsPropertiesTests: XCTestCase {
func testDailyActivePropertiesIncludeVersionAndBuild() {
let properties = PostHogAnalytics.dailyActiveProperties(
@@ -2300,6 +2337,20 @@ final class ZshShellIntegrationHandoffTests: XCTestCase {
XCTAssertTrue(output.contains("133;A;redraw=last;cl=line"), output)
}
+ func testShellIntegrationWinchGuardDoesNotPrintSpacerLineOnResize() throws {
+ let output = try runInteractiveZsh(
+ cmuxLoadGhosttyIntegration: false,
+ cmuxLoadShellIntegration: true,
+ command: """
+ print -r -- BEFORE
+ TRAPWINCH
+ print -r -- AFTER
+ """
+ )
+
+ XCTAssertEqual(output, "BEFORE\nAFTER", output)
+ }
+
private func runInteractiveZsh(cmuxLoadGhosttyIntegration: Bool) throws -> String {
try runInteractiveZsh(
cmuxLoadGhosttyIntegration: cmuxLoadGhosttyIntegration,
diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift
index 272d8b5a..9faffe0a 100644
--- a/cmuxTests/TerminalAndGhosttyTests.swift
+++ b/cmuxTests/TerminalAndGhosttyTests.swift
@@ -1756,6 +1756,14 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase {
window.contentView?.layoutSubtreeIfNeeded()
}
+ private func drainMainQueue() {
+ let expectation = XCTestExpectation(description: "drain main queue")
+ DispatchQueue.main.async {
+ expectation.fulfill()
+ }
+ XCTWaiter().wait(for: [expectation], timeout: 1.0)
+ }
+
func testPortalHostInstallsAboveContentViewForVisibility() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
@@ -2189,7 +2197,7 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase {
"Initial hit-testing should resolve the portal-hosted terminal at its original window position"
)
- TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows()
+ TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: window)
DispatchQueue.main.async {
shiftedContainer.frame.origin.x += 72
contentView.layoutSubtreeIfNeeded()
@@ -2226,6 +2234,306 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase {
"The delayed external sync should move the portal-hosted terminal to the queued layout shift position"
)
}
+
+ func testScheduledExternalGeometrySyncKeepsDragDrivenResizeResponsive() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 700, height: 420),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer {
+ NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window)
+ window.orderOut(nil)
+ }
+
+ let surface = TerminalSurface(
+ tabId: UUID(),
+ context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
+ configTemplate: nil,
+ workingDirectory: nil
+ )
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let shiftedContainer = NSView(frame: NSRect(x: 40, y: 60, width: 260, height: 180))
+ contentView.addSubview(shiftedContainer)
+ let anchor = NSView(frame: NSRect(x: 0, y: 0, width: 260, height: 180))
+ shiftedContainer.addSubview(anchor)
+ let hosted = surface.hostedView
+ TerminalWindowPortalRegistry.bind(
+ hostedView: hosted,
+ to: anchor,
+ visibleInUI: true,
+ expectedSurfaceId: surface.id,
+ expectedGeneration: surface.portalBindingGeneration()
+ )
+ TerminalWindowPortalRegistry.synchronizeForAnchor(anchor)
+ realizeWindowLayout(window)
+
+ let anchorCenter = NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY)
+ let originalWindowPoint = anchor.convert(anchorCenter, to: nil)
+ let originalAnchorFrameInWindow = anchor.convert(anchor.bounds, to: nil)
+ XCTAssertNotNil(
+ TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window),
+ "Initial hit-testing should resolve the portal-hosted terminal at its original window position"
+ )
+
+ TerminalWindowPortalRegistry.beginInteractiveGeometryResize()
+ defer {
+ TerminalWindowPortalRegistry.endInteractiveGeometryResize()
+ }
+
+ do {
+ shiftedContainer.frame.origin.x += 72
+ contentView.layoutSubtreeIfNeeded()
+ window.displayIfNeeded()
+ TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows()
+ }
+
+ drainMainQueue()
+
+ let shiftedAnchorFrameInWindow = anchor.convert(anchor.bounds, to: nil)
+ let retiredStaleWindowPoint = NSPoint(
+ x: (originalAnchorFrameInWindow.minX + shiftedAnchorFrameInWindow.minX) / 2,
+ y: shiftedAnchorFrameInWindow.midY
+ )
+ let shiftedWindowPoint = NSPoint(
+ x: (originalAnchorFrameInWindow.maxX + shiftedAnchorFrameInWindow.maxX) / 2,
+ y: shiftedAnchorFrameInWindow.midY
+ )
+ XCTAssertGreaterThan(
+ shiftedWindowPoint.x,
+ originalWindowPoint.x + 1,
+ "The drag handler should shift the anchor to the right"
+ )
+ XCTAssertNil(
+ TerminalWindowPortalRegistry.terminalViewAtWindowPoint(retiredStaleWindowPoint, in: window),
+ "Drag-driven geometry sync should clear the stale portal location on the next main-queue turn"
+ )
+ XCTAssertNotNil(
+ TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window),
+ "Drag-driven geometry sync should update the portal-hosted terminal without waiting an extra queue turn"
+ )
+ }
+
+ func testDragDrivenSidebarResizeDoesNotScheduleLateSecondTerminalResize() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 760, height: 420),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer {
+ NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window)
+ window.orderOut(nil)
+ }
+
+ let surface = TerminalSurface(
+ tabId: UUID(),
+ context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
+ configTemplate: nil,
+ workingDirectory: nil
+ )
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let shiftedContainer = NSView(frame: NSRect(x: 40, y: 60, width: 420, height: 220))
+ contentView.addSubview(shiftedContainer)
+ let anchor = NSView(frame: shiftedContainer.bounds)
+ anchor.autoresizingMask = [.width, .height]
+ shiftedContainer.addSubview(anchor)
+
+ let hosted = surface.hostedView
+ TerminalWindowPortalRegistry.bind(
+ hostedView: hosted,
+ to: anchor,
+ visibleInUI: true,
+ expectedSurfaceId: surface.id,
+ expectedGeneration: surface.portalBindingGeneration()
+ )
+ TerminalWindowPortalRegistry.synchronizeForAnchor(anchor)
+ realizeWindowLayout(window)
+ let originalHostedFrame = hosted.frame
+
+ TerminalWindowPortalRegistry.beginInteractiveGeometryResize()
+ defer {
+ TerminalWindowPortalRegistry.endInteractiveGeometryResize()
+ }
+
+ shiftedContainer.frame.origin.x += 72
+ shiftedContainer.frame.size.width -= 72
+ contentView.layoutSubtreeIfNeeded()
+ window.displayIfNeeded()
+ TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: window)
+
+ drainMainQueue()
+
+ let firstPassHostedFrame = hosted.frame
+ XCTAssertGreaterThan(
+ firstPassHostedFrame.minX,
+ originalHostedFrame.minX + 1,
+ "The sidebar drag should shift the hosted terminal on the first window-scoped sync pass"
+ )
+ XCTAssertLessThan(
+ firstPassHostedFrame.width,
+ originalHostedFrame.width - 1,
+ "The sidebar drag should resize the hosted terminal on the first window-scoped sync pass"
+ )
+
+ drainMainQueue()
+
+ let secondPassHostedFrame = hosted.frame
+ XCTAssertEqual(
+ secondPassHostedFrame.minX,
+ firstPassHostedFrame.minX,
+ accuracy: 0.5,
+ "Interactive sidebar resizes should not land a second delayed horizontal terminal shift on the next queue turn"
+ )
+ XCTAssertEqual(
+ secondPassHostedFrame.width,
+ firstPassHostedFrame.width,
+ accuracy: 0.5,
+ "Interactive sidebar resizes should not land a second delayed terminal resize on the next queue turn"
+ )
+ }
+
+ func testWindowScopedExternalGeometrySyncDoesNotRefreshOtherWindows() {
+ let firstWindow = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 700, height: 420),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer {
+ NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: firstWindow)
+ firstWindow.orderOut(nil)
+ }
+
+ let secondWindow = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 700, height: 420),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer {
+ NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: secondWindow)
+ secondWindow.orderOut(nil)
+ }
+
+ let firstSurface = TerminalSurface(
+ tabId: UUID(),
+ context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
+ configTemplate: nil,
+ workingDirectory: nil
+ )
+ let secondSurface = TerminalSurface(
+ tabId: UUID(),
+ context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
+ configTemplate: nil,
+ workingDirectory: nil
+ )
+
+ guard let firstContentView = firstWindow.contentView,
+ let secondContentView = secondWindow.contentView else {
+ XCTFail("Expected content views")
+ return
+ }
+
+ let firstContainer = NSView(frame: NSRect(x: 40, y: 60, width: 260, height: 180))
+ firstContentView.addSubview(firstContainer)
+ let firstAnchor = NSView(frame: NSRect(x: 0, y: 0, width: 260, height: 180))
+ firstContainer.addSubview(firstAnchor)
+
+ let secondContainer = NSView(frame: NSRect(x: 40, y: 60, width: 260, height: 180))
+ secondContentView.addSubview(secondContainer)
+ let secondAnchor = NSView(frame: NSRect(x: 0, y: 0, width: 260, height: 180))
+ secondContainer.addSubview(secondAnchor)
+
+ TerminalWindowPortalRegistry.bind(
+ hostedView: firstSurface.hostedView,
+ to: firstAnchor,
+ visibleInUI: true,
+ expectedSurfaceId: firstSurface.id,
+ expectedGeneration: firstSurface.portalBindingGeneration()
+ )
+ TerminalWindowPortalRegistry.bind(
+ hostedView: secondSurface.hostedView,
+ to: secondAnchor,
+ visibleInUI: true,
+ expectedSurfaceId: secondSurface.id,
+ expectedGeneration: secondSurface.portalBindingGeneration()
+ )
+ TerminalWindowPortalRegistry.synchronizeForAnchor(firstAnchor)
+ TerminalWindowPortalRegistry.synchronizeForAnchor(secondAnchor)
+ realizeWindowLayout(firstWindow)
+ realizeWindowLayout(secondWindow)
+
+ let originalFirstFrameInWindow = firstAnchor.convert(firstAnchor.bounds, to: nil)
+ let originalSecondFrameInWindow = secondAnchor.convert(secondAnchor.bounds, to: nil)
+
+ firstContainer.frame.origin.x += 72
+ secondContainer.frame.origin.x += 88
+ firstContentView.layoutSubtreeIfNeeded()
+ secondContentView.layoutSubtreeIfNeeded()
+ firstWindow.displayIfNeeded()
+ secondWindow.displayIfNeeded()
+
+ let shiftedFirstFrameInWindow = firstAnchor.convert(firstAnchor.bounds, to: nil)
+ let shiftedSecondFrameInWindow = secondAnchor.convert(secondAnchor.bounds, to: nil)
+ let retiredFirstPoint = NSPoint(
+ x: (originalFirstFrameInWindow.minX + shiftedFirstFrameInWindow.minX) / 2,
+ y: shiftedFirstFrameInWindow.midY
+ )
+ let shiftedFirstPoint = NSPoint(
+ x: (originalFirstFrameInWindow.maxX + shiftedFirstFrameInWindow.maxX) / 2,
+ y: shiftedFirstFrameInWindow.midY
+ )
+ let retiredSecondPoint = NSPoint(
+ x: (originalSecondFrameInWindow.minX + shiftedSecondFrameInWindow.minX) / 2,
+ y: shiftedSecondFrameInWindow.midY
+ )
+ let shiftedSecondPoint = NSPoint(
+ x: (originalSecondFrameInWindow.maxX + shiftedSecondFrameInWindow.maxX) / 2,
+ y: shiftedSecondFrameInWindow.midY
+ )
+ XCTAssertNil(
+ TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedFirstPoint, in: firstWindow),
+ "First window should remain stale until its scheduled external geometry sync runs"
+ )
+ XCTAssertNil(
+ TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedSecondPoint, in: secondWindow),
+ "Second window should remain stale until its scheduled external geometry sync runs"
+ )
+ XCTAssertNotNil(
+ TerminalWindowPortalRegistry.terminalViewAtWindowPoint(retiredSecondPoint, in: secondWindow),
+ "Before syncing, unrelated windows should still report the stale portal location"
+ )
+
+ TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: firstWindow)
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+
+ XCTAssertNil(
+ TerminalWindowPortalRegistry.terminalViewAtWindowPoint(retiredFirstPoint, in: firstWindow),
+ "Window-scoped sync should clear the stale location in the requested window"
+ )
+ XCTAssertNotNil(
+ TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedFirstPoint, in: firstWindow),
+ "Window-scoped sync should refresh the requested window"
+ )
+ XCTAssertNil(
+ TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedSecondPoint, in: secondWindow),
+ "Window-scoped sync should not refresh unrelated windows"
+ )
+ XCTAssertNotNil(
+ TerminalWindowPortalRegistry.terminalViewAtWindowPoint(retiredSecondPoint, in: secondWindow),
+ "Unrelated windows should retain their stale geometry until their own sync runs"
+ )
+ }
}
@@ -2370,6 +2678,17 @@ final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase {
)
)
}
+
+ func testInteractiveGeometryResizeUsesImmediatePortalSyncDecision() {
+ XCTAssertTrue(
+ GhosttyTerminalView.shouldSynchronizePortalGeometryImmediately(
+ hostInLiveResize: false,
+ windowInLiveResize: false,
+ interactiveGeometryResizeActive: true
+ ),
+ "Interactive resize should use the immediate portal sync path"
+ )
+ }
}
diff --git a/cmuxUITests/DisplayResolutionRegressionUITests.swift b/cmuxUITests/DisplayResolutionRegressionUITests.swift
new file mode 100644
index 00000000..579ae221
--- /dev/null
+++ b/cmuxUITests/DisplayResolutionRegressionUITests.swift
@@ -0,0 +1,386 @@
+import XCTest
+import Foundation
+
+final class DisplayResolutionRegressionUITests: XCTestCase {
+ private let displayHarnessManifestPath = "/tmp/cmux-ui-test-display-harness.json"
+ private var launchTag = ""
+ private var diagnosticsPath = ""
+ private var displayReadyPath = ""
+ private var displayIDPath = ""
+ private var displayStartPath = ""
+ private var displayDonePath = ""
+ private var helperBinaryPath = ""
+ private var helperLogPath = ""
+ private var launchedApp: XCUIApplication?
+ private var helperProcess: Process?
+
+ override func setUp() {
+ super.setUp()
+ continueAfterFailure = false
+
+ let token = UUID().uuidString
+ launchTag = "ui-tests-display-resolution-\(token.prefix(8))"
+ diagnosticsPath = "/tmp/cmux-ui-test-display-churn-\(token).json"
+ displayReadyPath = "/tmp/cmux-ui-test-display-ready-\(token)"
+ displayIDPath = "/tmp/cmux-ui-test-display-id-\(token)"
+ displayStartPath = "/tmp/cmux-ui-test-display-start-\(token)"
+ displayDonePath = "/tmp/cmux-ui-test-display-done-\(token)"
+ helperBinaryPath = "/tmp/cmux-ui-test-display-helper-\(token)"
+ helperLogPath = "/tmp/cmux-ui-test-display-helper-\(token).log"
+
+ removeTestArtifacts()
+ }
+
+ override func tearDown() {
+ terminateLaunchedAppIfNeeded()
+ helperProcess?.terminate()
+ helperProcess?.waitUntilExit()
+ helperProcess = nil
+ removeTestArtifacts()
+ super.tearDown()
+ }
+
+ func testRapidDisplayResolutionChangesKeepTerminalResponsive() throws {
+ try prepareDisplayHarnessIfNeeded()
+
+ XCTAssertTrue(waitForFile(atPath: displayReadyPath, timeout: 12.0), "Expected display harness ready file at \(displayReadyPath)")
+ guard let targetDisplayID = readTrimmedFile(atPath: displayIDPath), !targetDisplayID.isEmpty else {
+ XCTFail("Missing target display ID at \(displayIDPath)")
+ return
+ }
+
+ try launchAppProcess(targetDisplayID: targetDisplayID)
+ XCTAssertTrue(
+ waitForTargetDisplayMove(targetDisplayID: targetDisplayID, timeout: 12.0),
+ "Expected app window to move to display \(targetDisplayID). diagnostics=\(loadDiagnostics() ?? [:]) app=\(launchedAppDiagnostics())"
+ )
+
+ guard let baselineStats = waitForRenderStats(timeout: 8.0) else {
+ XCTFail("Missing initial render stats. diagnostics=\(loadDiagnostics() ?? [:])")
+ return
+ }
+ let baselinePresentCount = baselineStats.presentCount
+ var maxPresentCount = baselinePresentCount
+ var maxDiagnosticsUpdatedAt = baselineStats.diagnosticsUpdatedAt
+ var lastStats = baselineStats
+
+ do {
+ try Data("start\n".utf8).write(to: URL(fileURLWithPath: displayStartPath), options: .atomic)
+ } catch {
+ XCTFail("Expected start signal file to be created at \(displayStartPath): \(error)")
+ return
+ }
+
+ let deadline = Date().addingTimeInterval(30.0)
+ while Date() < deadline {
+ if let stats = loadRenderStats() {
+ lastStats = stats
+ maxPresentCount = max(maxPresentCount, stats.presentCount)
+ maxDiagnosticsUpdatedAt = max(maxDiagnosticsUpdatedAt, stats.diagnosticsUpdatedAt)
+ }
+
+ let doneMarker = readTrimmedFile(atPath: displayDonePath)
+ if doneMarker == "done" && maxPresentCount >= baselinePresentCount + 8 {
+ break
+ }
+ if let doneMarker, doneMarker.hasPrefix("error:") {
+ XCTFail("Display churn helper failed: \(doneMarker). log=\(readTrimmedFile(atPath: helperLogPath) ?? "")")
+ return
+ }
+ RunLoop.current.run(until: Date().addingTimeInterval(0.15))
+ }
+
+ XCTAssertEqual(
+ readTrimmedFile(atPath: displayDonePath),
+ "done",
+ "Expected display churn to finish. helperLog=\(readTrimmedFile(atPath: helperLogPath) ?? "")"
+ )
+
+ guard let finalStats = waitForRenderStats(timeout: 6.0) else {
+ XCTFail("Expected render stats after display churn. diagnostics=\(loadDiagnostics() ?? [:])")
+ return
+ }
+
+ maxPresentCount = max(maxPresentCount, finalStats.presentCount)
+ maxDiagnosticsUpdatedAt = max(maxDiagnosticsUpdatedAt, finalStats.diagnosticsUpdatedAt)
+
+ XCTAssertGreaterThanOrEqual(
+ maxPresentCount - baselinePresentCount,
+ 8,
+ "Expected terminal presents to keep advancing during display churn. baseline=\(baselineStats) last=\(lastStats) final=\(finalStats)"
+ )
+ XCTAssertGreaterThan(
+ maxDiagnosticsUpdatedAt,
+ baselineStats.diagnosticsUpdatedAt,
+ "Expected render diagnostics to keep updating during display churn. baseline=\(baselineStats) final=\(finalStats)"
+ )
+ }
+
+ private func prepareDisplayHarnessIfNeeded() throws {
+ let env = ProcessInfo.processInfo.environment
+ if let externalHarness = loadExternalHarnessFromEnvironment(env) ?? loadExternalHarnessFromManifest() {
+ displayReadyPath = externalHarness.readyPath
+ displayIDPath = externalHarness.displayIDPath
+ displayStartPath = externalHarness.startPath
+ displayDonePath = externalHarness.donePath
+ if let logPath = externalHarness.logPath, !logPath.isEmpty {
+ helperLogPath = logPath
+ }
+ return
+ }
+
+ try buildDisplayHelper()
+ try launchDisplayHelper()
+ }
+
+ private func loadExternalHarnessFromEnvironment(_ env: [String: String]) -> ExternalDisplayHarness? {
+ guard let readyPath = env["CMUX_UI_TEST_DISPLAY_READY_PATH"], !readyPath.isEmpty,
+ let displayIDPath = env["CMUX_UI_TEST_DISPLAY_ID_PATH"], !displayIDPath.isEmpty,
+ let startPath = env["CMUX_UI_TEST_DISPLAY_START_PATH"], !startPath.isEmpty,
+ let donePath = env["CMUX_UI_TEST_DISPLAY_DONE_PATH"], !donePath.isEmpty else {
+ return nil
+ }
+
+ return ExternalDisplayHarness(
+ readyPath: readyPath,
+ displayIDPath: displayIDPath,
+ startPath: startPath,
+ donePath: donePath,
+ logPath: env["CMUX_UI_TEST_DISPLAY_LOG_PATH"]
+ )
+ }
+
+ private func loadExternalHarnessFromManifest() -> ExternalDisplayHarness? {
+ let manifestURL = URL(fileURLWithPath: displayHarnessManifestPath)
+ guard let data = try? Data(contentsOf: manifestURL) else {
+ return nil
+ }
+ return try? JSONDecoder().decode(ExternalDisplayHarness.self, from: data)
+ }
+
+ private func buildDisplayHelper() throws {
+ let sourceURL = repoRootURL.appendingPathComponent("scripts/create-virtual-display.m")
+
+ let proc = Process()
+ proc.executableURL = URL(fileURLWithPath: "/usr/bin/clang")
+ proc.arguments = [
+ "-framework", "Foundation",
+ "-framework", "CoreGraphics",
+ "-o", helperBinaryPath,
+ sourceURL.path,
+ ]
+
+ let stderrPipe = Pipe()
+ proc.standardError = stderrPipe
+
+ try proc.run()
+ proc.waitUntilExit()
+
+ guard proc.terminationStatus == 0 else {
+ let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
+ throw NSError(domain: "DisplayResolutionRegressionUITests", code: Int(proc.terminationStatus), userInfo: [
+ NSLocalizedDescriptionKey: "Failed to build display helper: \(stderr)"
+ ])
+ }
+ }
+
+ private func launchDisplayHelper() throws {
+ let proc = Process()
+ proc.executableURL = URL(fileURLWithPath: helperBinaryPath)
+ proc.arguments = [
+ "--modes", "1920x1080,1728x1117,1600x900,1440x810",
+ "--ready-path", displayReadyPath,
+ "--display-id-path", displayIDPath,
+ "--start-path", displayStartPath,
+ "--done-path", displayDonePath,
+ "--iterations", "40",
+ "--interval-ms", "40",
+ ]
+
+ let logHandle = FileHandle(forWritingAtPath: helperLogPath) ?? {
+ FileManager.default.createFile(atPath: helperLogPath, contents: nil)
+ return FileHandle(forWritingAtPath: helperLogPath)
+ }()
+ proc.standardOutput = logHandle
+ proc.standardError = logHandle
+
+ try proc.run()
+ helperProcess = proc
+ }
+
+ private func launchAppProcess(targetDisplayID: String) throws {
+ let app = XCUIApplication()
+ for (key, value) in launchEnvironment(targetDisplayID: targetDisplayID) {
+ app.launchEnvironment[key] = value
+ }
+ app.launch()
+ guard ensureForegroundAfterLaunch(app, timeout: 12.0) else {
+ throw NSError(domain: "DisplayResolutionRegressionUITests", code: 2, userInfo: [
+ NSLocalizedDescriptionKey: "XCUIApplication failed to reach foreground. state=\(app.state.rawValue)"
+ ])
+ }
+ launchedApp = app
+ }
+
+ private func launchEnvironment(targetDisplayID: String) -> [String: String] {
+ [
+ "CMUX_UI_TEST_MODE": "1",
+ "CMUX_UI_TEST_DIAGNOSTICS_PATH": diagnosticsPath,
+ "CMUX_UI_TEST_DISPLAY_RENDER_STATS": "1",
+ "CMUX_UI_TEST_TARGET_DISPLAY_ID": targetDisplayID,
+ "CMUX_TAG": launchTag,
+ ]
+ }
+
+ private func terminateLaunchedAppIfNeeded() {
+ guard let launchedApp else { return }
+ defer { self.launchedApp = nil }
+
+ if launchedApp.state == .notRunning {
+ return
+ }
+
+ launchedApp.terminate()
+ _ = launchedApp.wait(for: .notRunning, timeout: 5.0)
+ }
+
+ private func launchedAppDiagnostics() -> String {
+ guard let launchedApp else { return "not-launched" }
+ return "state=\(launchedApp.state.rawValue)"
+ }
+
+ private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {
+ if app.wait(for: .runningForeground, timeout: timeout) {
+ return true
+ }
+ if app.state == .runningBackground {
+ app.activate()
+ return app.wait(for: .runningForeground, timeout: 6.0)
+ }
+ return false
+ }
+
+ private func waitForTargetDisplayMove(targetDisplayID: String, timeout: TimeInterval) -> Bool {
+ waitForCondition(timeout: timeout) {
+ guard let diagnostics = self.loadDiagnostics() else { return false }
+ return diagnostics["targetDisplayMoveSucceeded"] == "1" &&
+ diagnostics["windowScreenDisplayIDs"]?.contains(targetDisplayID) == true
+ }
+ }
+
+ private func waitForRenderStats(timeout: TimeInterval) -> RenderStats? {
+ let deadline = Date().addingTimeInterval(timeout)
+ while Date() < deadline {
+ if let stats = loadRenderStats() {
+ return stats
+ }
+ RunLoop.current.run(until: Date().addingTimeInterval(0.2))
+ }
+ return loadRenderStats()
+ }
+
+ private func loadRenderStats() -> RenderStats? {
+ guard let diagnostics = loadDiagnostics() else { return nil }
+ return RenderStats(diagnostics: diagnostics)
+ }
+
+ private func loadDiagnostics() -> [String: String]? {
+ guard let data = try? Data(contentsOf: URL(fileURLWithPath: diagnosticsPath)),
+ let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else {
+ return nil
+ }
+ return object
+ }
+
+ private func waitForCondition(timeout: TimeInterval, pollInterval: TimeInterval = 0.15, _ condition: () -> Bool) -> Bool {
+ let deadline = Date().addingTimeInterval(timeout)
+ while Date() < deadline {
+ if condition() {
+ return true
+ }
+ RunLoop.current.run(until: Date().addingTimeInterval(pollInterval))
+ }
+ return condition()
+ }
+
+ private func waitForFile(atPath path: String, timeout: TimeInterval) -> Bool {
+ waitForCondition(timeout: timeout) {
+ FileManager.default.fileExists(atPath: path)
+ }
+ }
+
+ private func readTrimmedFile(atPath path: String) -> String? {
+ guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)),
+ let value = String(data: data, encoding: .utf8) else {
+ return nil
+ }
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.isEmpty ? nil : trimmed
+ }
+
+ private var repoRootURL: URL {
+ URL(fileURLWithPath: #filePath)
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ }
+
+ private func removeTestArtifacts() {
+ for path in [
+ diagnosticsPath,
+ displayReadyPath,
+ displayIDPath,
+ displayStartPath,
+ displayDonePath,
+ helperBinaryPath,
+ helperLogPath,
+ ] {
+ guard !path.isEmpty else { continue }
+ try? FileManager.default.removeItem(atPath: path)
+ }
+ }
+
+ private struct RenderStats: CustomStringConvertible {
+ let panelId: String
+ let drawCount: Int
+ let presentCount: Int
+ let lastPresentTime: Double
+ let windowVisible: Bool
+ let appIsActive: Bool
+ let desiredFocus: Bool
+ let isFirstResponder: Bool
+ let diagnosticsUpdatedAt: Double
+
+ init?(diagnostics: [String: String]) {
+ guard diagnostics["renderStatsAvailable"] == "1",
+ let panelId = diagnostics["renderPanelId"], !panelId.isEmpty,
+ let drawCount = Int(diagnostics["renderDrawCount"] ?? ""),
+ let presentCount = Int(diagnostics["renderPresentCount"] ?? ""),
+ let lastPresentTime = Double(diagnostics["renderLastPresentTime"] ?? ""),
+ let diagnosticsUpdatedAt = Double(diagnostics["renderDiagnosticsUpdatedAt"] ?? "") else {
+ return nil
+ }
+
+ self.panelId = panelId
+ self.drawCount = drawCount
+ self.presentCount = presentCount
+ self.lastPresentTime = lastPresentTime
+ self.windowVisible = diagnostics["renderWindowVisible"] == "1"
+ self.appIsActive = diagnostics["renderAppIsActive"] == "1"
+ self.desiredFocus = diagnostics["renderDesiredFocus"] == "1"
+ self.isFirstResponder = diagnostics["renderIsFirstResponder"] == "1"
+ self.diagnosticsUpdatedAt = diagnosticsUpdatedAt
+ }
+
+ var description: String {
+ "panel=\(panelId) draw=\(drawCount) present=\(presentCount) lastPresent=\(String(format: "%.3f", lastPresentTime)) visible=\(windowVisible) active=\(appIsActive) desiredFocus=\(desiredFocus) firstResponder=\(isFirstResponder) updatedAt=\(String(format: "%.3f", diagnosticsUpdatedAt))"
+ }
+ }
+
+ private struct ExternalDisplayHarness: Decodable {
+ let readyPath: String
+ let displayIDPath: String
+ let startPath: String
+ let donePath: String
+ let logPath: String?
+ }
+}
diff --git a/scripts/create-virtual-display.m b/scripts/create-virtual-display.m
index d3df1bae..f87ab2bd 100644
--- a/scripts/create-virtual-display.m
+++ b/scripts/create-virtual-display.m
@@ -1,11 +1,14 @@
// Creates a virtual display on headless macOS (CI runners without a physical monitor).
// Uses the private CGVirtualDisplay API from CoreGraphics.
-// The display stays alive as long as this process runs.
+// The display stays alive as long as this process runs and can optionally churn
+// through multiple display modes after a start signal file appears.
//
// Build: clang -framework Foundation -framework CoreGraphics -o create-virtual-display create-virtual-display.m
// Usage: ./create-virtual-display &
#import
+#import
+#import
#import
// Private CoreGraphics classes (declared here since they're not in public headers)
@@ -35,10 +38,141 @@
@property (nonatomic, readonly) unsigned int displayID;
@end
+static NSArray *> *defaultModeSpecs(void) {
+ return @[
+ @{@"width": @1920, @"height": @1080},
+ ];
+}
+
+static void writeString(NSString *value, NSString *path) {
+ if (path.length == 0) { return; }
+ NSError *error = nil;
+ BOOL ok = [value writeToFile:path atomically:YES encoding:NSUTF8StringEncoding error:&error];
+ if (!ok && error) {
+ fprintf(stderr, "ERROR: Failed to write %s (%s)\n", path.UTF8String, error.localizedDescription.UTF8String);
+ }
+}
+
+static NSDictionary *parseModeSpec(NSString *raw) {
+ NSArray *parts = [raw.lowercaseString componentsSeparatedByString:@"x"];
+ if (parts.count != 2) { return nil; }
+
+ NSInteger width = parts[0].integerValue;
+ NSInteger height = parts[1].integerValue;
+ if (width <= 0 || height <= 0) { return nil; }
+
+ return @{
+ @"width": @(width),
+ @"height": @(height),
+ };
+}
+
+static NSArray *> *parseModeList(NSString *raw) {
+ if (raw.length == 0) { return defaultModeSpecs(); }
+
+ NSMutableArray *> *modes = [NSMutableArray array];
+ for (NSString *token in [raw componentsSeparatedByString:@","]) {
+ NSString *trimmed = [token stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
+ if (trimmed.length == 0) { continue; }
+ NSDictionary *parsed = parseModeSpec(trimmed);
+ if (!parsed) {
+ fprintf(stderr, "ERROR: Invalid mode spec: %s\n", trimmed.UTF8String);
+ return nil;
+ }
+ [modes addObject:parsed];
+ }
+
+ if (modes.count == 0) {
+ return defaultModeSpecs();
+ }
+ return modes;
+}
+
+static NSString *modeLabel(CGDisplayModeRef mode) {
+ return [NSString stringWithFormat:@"%zux%zu", CGDisplayModeGetWidth(mode), CGDisplayModeGetHeight(mode)];
+}
+
+static NSArray *resolveRequestedModes(CGDirectDisplayID displayID, NSArray *> *requestedModes) {
+ NSArray *availableModes = CFBridgingRelease(CGDisplayCopyAllDisplayModes(displayID, NULL));
+ if (availableModes.count == 0) {
+ fprintf(stderr, "ERROR: No CoreGraphics display modes found for display %u\n", displayID);
+ return nil;
+ }
+
+ NSMutableArray *resolved = [NSMutableArray array];
+ for (NSDictionary *modeSpec in requestedModes) {
+ size_t requestedWidth = modeSpec[@"width"].unsignedIntegerValue;
+ size_t requestedHeight = modeSpec[@"height"].unsignedIntegerValue;
+
+ id matched = nil;
+ for (id candidate in availableModes) {
+ CGDisplayModeRef mode = (__bridge CGDisplayModeRef)candidate;
+ if (CGDisplayModeGetWidth(mode) == requestedWidth &&
+ CGDisplayModeGetHeight(mode) == requestedHeight) {
+ matched = candidate;
+ break;
+ }
+ }
+
+ if (!matched) {
+ fprintf(stderr, "ERROR: Requested display mode %zux%zu not available\n", requestedWidth, requestedHeight);
+ fprintf(stderr, "Available modes:");
+ for (id candidate in availableModes) {
+ CGDisplayModeRef mode = (__bridge CGDisplayModeRef)candidate;
+ fprintf(stderr, " %s", modeLabel(mode).UTF8String);
+ }
+ fprintf(stderr, "\n");
+ return nil;
+ }
+
+ [resolved addObject:matched];
+ }
+
+ return resolved;
+}
+
+static NSString *argumentValue(NSArray *arguments, NSString *flag) {
+ NSString *prefix = [flag stringByAppendingString:@"="];
+ for (NSUInteger i = 0; i < arguments.count; i += 1) {
+ NSString *arg = arguments[i];
+ if ([arg isEqualToString:flag]) {
+ if (i + 1 < arguments.count) {
+ return arguments[i + 1];
+ }
+ return @"";
+ }
+ if ([arg hasPrefix:prefix]) {
+ return [arg substringFromIndex:prefix.length];
+ }
+ }
+ return nil;
+}
+
int main(int argc, const char *argv[]) {
@autoreleasepool {
- unsigned int width = 1920;
- unsigned int height = 1080;
+ NSArray *arguments = [[NSProcessInfo processInfo] arguments];
+
+ NSString *modesArgument = argumentValue(arguments, @"--modes");
+ NSArray *> *modeSpecs = parseModeList(modesArgument);
+ if (!modeSpecs) {
+ return 1;
+ }
+
+ NSString *readyPath = argumentValue(arguments, @"--ready-path") ?: @"";
+ NSString *displayIDPath = argumentValue(arguments, @"--display-id-path") ?: @"";
+ NSString *startPath = argumentValue(arguments, @"--start-path") ?: @"";
+ NSString *donePath = argumentValue(arguments, @"--done-path") ?: @"";
+ NSInteger iterations = MAX(0, [argumentValue(arguments, @"--iterations") integerValue]);
+ NSString *intervalArgument = argumentValue(arguments, @"--interval-ms");
+ NSInteger intervalMs = intervalArgument.length > 0 ? intervalArgument.integerValue : 40;
+ useconds_t intervalMicros = (useconds_t)(MAX(1, intervalMs) * 1000);
+
+ unsigned int width = 0;
+ unsigned int height = 0;
+ for (NSDictionary *spec in modeSpecs) {
+ width = MAX(width, spec[@"width"].unsignedIntValue);
+ height = MAX(height, spec[@"height"].unsignedIntValue);
+ }
// Verify the private classes exist
if (!NSClassFromString(@"CGVirtualDisplay")) {
@@ -46,11 +180,16 @@ int main(int argc, const char *argv[]) {
return 1;
}
- // Create display mode
- CGVirtualDisplayMode *mode = [[CGVirtualDisplayMode alloc] initWithWidth:width height:height refreshRate:60.0];
- if (!mode) {
- fprintf(stderr, "ERROR: Failed to create CGVirtualDisplayMode\n");
- return 1;
+ NSMutableArray *modes = [NSMutableArray array];
+ for (NSDictionary *spec in modeSpecs) {
+ CGVirtualDisplayMode *mode = [[CGVirtualDisplayMode alloc] initWithWidth:spec[@"width"].unsignedIntValue
+ height:spec[@"height"].unsignedIntValue
+ refreshRate:60.0];
+ if (!mode) {
+ fprintf(stderr, "ERROR: Failed to create CGVirtualDisplayMode\n");
+ return 1;
+ }
+ [modes addObject:mode];
}
// Configure descriptor
@@ -74,7 +213,7 @@ int main(int argc, const char *argv[]) {
// Apply settings with display mode
CGVirtualDisplaySettings *settings = [[CGVirtualDisplaySettings alloc] init];
settings.hiDPI = 0;
- settings.modes = @[mode];
+ settings.modes = modes;
BOOL ok = [display applySettings:settings];
if (!ok) {
@@ -85,6 +224,45 @@ int main(int argc, const char *argv[]) {
printf("Virtual display created: %ux%u@60Hz (displayID: %u)\n", width, height, display.displayID);
printf("PID: %d\n", getpid());
fflush(stdout);
+ writeString([NSString stringWithFormat:@"%u\n", display.displayID], displayIDPath);
+ writeString(@"ready\n", readyPath);
+
+ if (iterations > 0 && modeSpecs.count > 1) {
+ dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
+ if (startPath.length > 0) {
+ while (![[NSFileManager defaultManager] fileExistsAtPath:startPath]) {
+ usleep(20 * 1000);
+ }
+ }
+
+ NSArray *resolvedModes = resolveRequestedModes(display.displayID, modeSpecs);
+ if (resolvedModes.count < 2) {
+ writeString(@"error:no_modes\n", donePath);
+ return;
+ }
+
+ CGError setError = CGDisplaySetDisplayMode(display.displayID, (__bridge CGDisplayModeRef)resolvedModes.firstObject, NULL);
+ if (setError != kCGErrorSuccess) {
+ fprintf(stderr, "ERROR: Failed to set initial display mode (%d)\n", setError);
+ writeString([NSString stringWithFormat:@"error:%d\n", setError], donePath);
+ return;
+ }
+
+ for (NSInteger i = 0; i < iterations; i += 1) {
+ NSUInteger targetIndex = (NSUInteger)((i + 1) % resolvedModes.count);
+ id targetMode = resolvedModes[targetIndex];
+ CGError churnError = CGDisplaySetDisplayMode(display.displayID, (__bridge CGDisplayModeRef)targetMode, NULL);
+ if (churnError != kCGErrorSuccess) {
+ fprintf(stderr, "ERROR: Failed to switch display mode at iteration %ld (%d)\n", (long)i, churnError);
+ writeString([NSString stringWithFormat:@"error:%d\n", churnError], donePath);
+ return;
+ }
+ usleep(intervalMicros);
+ }
+
+ writeString(@"done\n", donePath);
+ });
+ }
// Keep alive so the display persists
dispatch_main();
diff --git a/tests/test_ci_self_hosted_guard.sh b/tests/test_ci_self_hosted_guard.sh
index c3a5281c..5e22c00f 100755
--- a/tests/test_ci_self_hosted_guard.sh
+++ b/tests/test_ci_self_hosted_guard.sh
@@ -39,5 +39,18 @@ if ! awk '
exit 1
fi
+# ui-display-resolution-regression: must use WarpBuild runner with fork guard (paid runner)
+if ! awk '
+ /^ ui-display-resolution-regression:/ { in_tests=1; next }
+ in_tests && /^ [^[:space:]]/ { in_tests=0 }
+ in_tests && /runs-on: warp-macos-15-arm64-6x/ { saw_warp=1 }
+ in_tests && /github.event.pull_request.head.repo.full_name == github.repository/ { saw_guard=1 }
+ END { exit !(saw_warp && saw_guard) }
+' "$WORKFLOW_FILE"; then
+ echo "FAIL: ui-display-resolution-regression block must keep both warp-macos-15-arm64-6x runner and fork guard"
+ exit 1
+fi
+
echo "PASS: tests WarpBuild runner fork guard is present"
echo "PASS: tests-build-and-lag WarpBuild runner fork guard is present"
+echo "PASS: ui-display-resolution-regression WarpBuild runner fork guard is present"
diff --git a/vendor/bonsplit b/vendor/bonsplit
index efa23f4c..02fa188c 160000
--- a/vendor/bonsplit
+++ b/vendor/bonsplit
@@ -1 +1 @@
-Subproject commit efa23f4c3c7d00688d8448dc7e4d08b4d847548d
+Subproject commit 02fa188ccd244b1e6efc037e4ed631e966144795
From 387742a5a0960833961a6c1d56b8a6288fd05b2f Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Wed, 18 Mar 2026 01:32:12 -0700
Subject: [PATCH 09/24] Update all remaining cmux.dev references to cmux.com
(#1721)
- Swift app: feedback API endpoint, docs URLs, changelog URL, CLI help
- PostHog proxy: r.cmux.dev -> r.cmux.com
- All 20 README files: docs and blog links
- Homebrew cask: homepage URL in update-homebrew workflow
Co-authored-by: Lawrence Chen
---
.github/workflows/update-homebrew.yml | 2 +-
CLI/cmux.swift | 2 +-
README.ar.md | 4 ++--
README.bs.md | 4 ++--
README.da.md | 4 ++--
README.de.md | 4 ++--
README.es.md | 4 ++--
README.fr.md | 4 ++--
README.it.md | 4 ++--
README.ja.md | 4 ++--
README.km.md | 4 ++--
README.ko.md | 4 ++--
README.md | 4 ++--
README.no.md | 4 ++--
README.pl.md | 4 ++--
README.pt-BR.md | 4 ++--
README.ru.md | 4 ++--
README.th.md | 4 ++--
README.tr.md | 4 ++--
README.vi.md | 4 ++--
README.zh-CN.md | 4 ++--
README.zh-TW.md | 4 ++--
Sources/ContentView.swift | 6 +++---
Sources/cmuxApp.swift | 2 +-
web/app/[locale]/posthog.tsx | 2 +-
25 files changed, 47 insertions(+), 47 deletions(-)
diff --git a/.github/workflows/update-homebrew.yml b/.github/workflows/update-homebrew.yml
index b8c4d705..e389dc92 100644
--- a/.github/workflows/update-homebrew.yml
+++ b/.github/workflows/update-homebrew.yml
@@ -97,7 +97,7 @@ jobs:
url "https://github.com/manaflow-ai/cmux/releases/download/v#{version}/cmux-macos.dmg"
name "cmux"
desc "Lightweight native macOS terminal with vertical tabs for AI coding agents"
- homepage "https://cmux.dev"
+ homepage "https://cmux.com"
livecheck do
url :url
diff --git a/CLI/cmux.swift b/CLI/cmux.swift
index c6495bbb..7b4e4f5a 100644
--- a/CLI/cmux.swift
+++ b/CLI/cmux.swift
@@ -10720,7 +10720,7 @@ struct CMUXCLI {
print()
print(shortcuts)
print()
- print(" \(bold)Docs\(reset)\(subdued) https://cmux.dev/docs\(reset)")
+ print(" \(bold)Docs\(reset)\(subdued) https://cmux.com/docs\(reset)")
print(" \(bold)Discord\(reset)\(subdued) https://discord.gg/xsgFEVrWCZ\(reset)")
print(" \(bold)GitHub\(reset)\(subdued) https://github.com/manaflow-ai/cmux (please leave a star ⭐)\(reset)")
print(" \(bold)Email\(reset)\(subdued) founders@manaflow.com\(reset)")
diff --git a/README.ar.md b/README.ar.md
index 52785b7a..7edc241e 100644
--- a/README.ar.md
+++ b/README.ar.md
@@ -23,7 +23,7 @@
- ▶ فيديو توضيحي · فلسفة cmux
+ ▶ فيديو توضيحي · فلسفة cmux
## الميزات
@@ -121,7 +121,7 @@ cmux هو لبنة أساسية وليس حلًا جاهزًا. يمنحك طر
## التوثيق
-لمزيد من المعلومات حول كيفية إعداد cmux، [توجه إلى وثائقنا](https://cmux.dev/docs/getting-started?utm_source=readme).
+لمزيد من المعلومات حول كيفية إعداد cmux، [توجه إلى وثائقنا](https://cmux.com/docs/getting-started?utm_source=readme).
## اختصارات لوحة المفاتيح
diff --git a/README.bs.md b/README.bs.md
index 5b16c652..b053f59f 100644
--- a/README.bs.md
+++ b/README.bs.md
@@ -23,7 +23,7 @@
- ▶ Demo video · The Zen of cmux
+ ▶ Demo video · The Zen of cmux
## Funkcije
@@ -121,7 +121,7 @@ Dajte milion programera kompozabilne primitive i oni će kolektivno pronaći naj
## Dokumentacija
-Za više informacija o konfiguraciji cmux, posjetite [našu dokumentaciju](https://cmux.dev/docs/getting-started?utm_source=readme).
+Za više informacija o konfiguraciji cmux, posjetite [našu dokumentaciju](https://cmux.com/docs/getting-started?utm_source=readme).
## Prečice na Tastaturi
diff --git a/README.da.md b/README.da.md
index bf08e8e8..7d09bac4 100644
--- a/README.da.md
+++ b/README.da.md
@@ -23,7 +23,7 @@
- ▶ Demovideo · The Zen of cmux
+ ▶ Demovideo · The Zen of cmux
## Funktioner
@@ -121,7 +121,7 @@ Giv en million udviklere komponerbare primitiver, og de vil kollektivt finde de
## Dokumentation
-For mere information om konfiguration af cmux, [se vores dokumentation](https://cmux.dev/docs/getting-started?utm_source=readme).
+For mere information om konfiguration af cmux, [se vores dokumentation](https://cmux.com/docs/getting-started?utm_source=readme).
## Tastaturgenveje
diff --git a/README.de.md b/README.de.md
index 2c3b3581..d7d68621 100644
--- a/README.de.md
+++ b/README.de.md
@@ -23,7 +23,7 @@
- ▶ Demo-Video · The Zen of cmux
+ ▶ Demo-Video · The Zen of cmux
## Funktionen
@@ -121,7 +121,7 @@ Geben Sie einer Million Entwickler komponierbare Grundbausteine, und sie werden
## Dokumentation
-Weitere Informationen zur Konfiguration von cmux finden Sie in [unserer Dokumentation](https://cmux.dev/docs/getting-started?utm_source=readme).
+Weitere Informationen zur Konfiguration von cmux finden Sie in [unserer Dokumentation](https://cmux.com/docs/getting-started?utm_source=readme).
## Tastenkürzel
diff --git a/README.es.md b/README.es.md
index 01a6d051..cc492a14 100644
--- a/README.es.md
+++ b/README.es.md
@@ -23,7 +23,7 @@
- ▶ Video de demostración · The Zen of cmux
+ ▶ Video de demostración · The Zen of cmux
## Características
@@ -121,7 +121,7 @@ Dale a un millón de desarrolladores primitivos componibles y encontrarán colec
## Documentación
-Para más información sobre cómo configurar cmux, [visita nuestra documentación](https://cmux.dev/docs/getting-started?utm_source=readme).
+Para más información sobre cómo configurar cmux, [visita nuestra documentación](https://cmux.com/docs/getting-started?utm_source=readme).
## Atajos de teclado
diff --git a/README.fr.md b/README.fr.md
index 462f6d9b..91e84e6c 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -23,7 +23,7 @@
- ▶ Vidéo de démonstration · The Zen of cmux
+ ▶ Vidéo de démonstration · The Zen of cmux
## Fonctionnalités
@@ -121,7 +121,7 @@ Donnez à un million de développeurs des primitives composables et ils trouvero
## Documentation
-Pour plus d'informations sur la configuration de cmux, [consultez notre documentation](https://cmux.dev/docs/getting-started?utm_source=readme).
+Pour plus d'informations sur la configuration de cmux, [consultez notre documentation](https://cmux.com/docs/getting-started?utm_source=readme).
## Raccourcis clavier
diff --git a/README.it.md b/README.it.md
index bb515256..46e30889 100644
--- a/README.it.md
+++ b/README.it.md
@@ -23,7 +23,7 @@
- ▶ Video demo · The Zen of cmux
+ ▶ Video demo · The Zen of cmux
## Funzionalità
@@ -121,7 +121,7 @@ Date a un milione di sviluppatori primitive componibili e troveranno collettivam
## Documentazione
-Per maggiori informazioni su come configurare cmux, [consulta la nostra documentazione](https://cmux.dev/docs/getting-started?utm_source=readme).
+Per maggiori informazioni su come configurare cmux, [consulta la nostra documentazione](https://cmux.com/docs/getting-started?utm_source=readme).
## Scorciatoie da Tastiera
diff --git a/README.ja.md b/README.ja.md
index 074cdd91..dd1fd226 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -23,7 +23,7 @@
- ▶ デモ動画 · The Zen of cmux
+ ▶ デモ動画 · The Zen of cmux
## 機能
@@ -121,7 +121,7 @@ cmuxはソリューションではなくプリミティブです。ターミナ
## ドキュメント
-cmuxの設定方法の詳細は、[ドキュメントをご覧ください](https://cmux.dev/docs/getting-started?utm_source=readme)。
+cmuxの設定方法の詳細は、[ドキュメントをご覧ください](https://cmux.com/docs/getting-started?utm_source=readme)。
## キーボードショートカット
diff --git a/README.km.md b/README.km.md
index 19d3be94..7a8d1e89 100644
--- a/README.km.md
+++ b/README.km.md
@@ -23,7 +23,7 @@
- ▶ វីដេអូបង្ហាញពីដំណើរការ (Demo) · ទស្សនវិជ្ជារបស់ cmux (The Zen of cmux)
+ ▶ វីដេអូបង្ហាញពីដំណើរការ (Demo) · ទស្សនវិជ្ជារបស់ cmux (The Zen of cmux)
## លក្ខណៈពិសេសនានា (Features)
@@ -121,7 +121,7 @@ cmux គឺជាមូលដ្ឋានគ្រឹះ (primitive) មិន
## ឯកសារ (Documentation)
-សម្រាប់ព័ត៌មានបន្ថែមអំពីរបៀបកំណត់រចនាសម្ព័ន្ធ cmux, [សូមចូលទៅកាន់ឯកសាររបស់យើង](https://cmux.dev/docs/getting-started?utm_source=readme)។
+សម្រាប់ព័ត៌មានបន្ថែមអំពីរបៀបកំណត់រចនាសម្ព័ន្ធ cmux, [សូមចូលទៅកាន់ឯកសាររបស់យើង](https://cmux.com/docs/getting-started?utm_source=readme)។
## គ្រាប់ចុចផ្លូវកាត់ (Keyboard Shortcuts)
diff --git a/README.ko.md b/README.ko.md
index 8d92b94b..092a98c9 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -23,7 +23,7 @@
- ▶ 데모 영상 · The Zen of cmux
+ ▶ 데모 영상 · The Zen of cmux
## 기능
@@ -121,7 +121,7 @@ cmux는 솔루션이 아니라 프리미티브예요. 터미널, 브라우저,
## 문서
-cmux 설정 방법에 대한 자세한 내용은 [문서를 확인해주세요](https://cmux.dev/docs/getting-started?utm_source=readme).
+cmux 설정 방법에 대한 자세한 내용은 [문서를 확인해주세요](https://cmux.com/docs/getting-started?utm_source=readme).
## 키보드 단축키
diff --git a/README.md b/README.md
index 91c6ccbb..2566840f 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@
- ▶ Demo video · The Zen of cmux
+ ▶ Demo video · The Zen of cmux
## Features
@@ -119,7 +119,7 @@ Give a million developers composable primitives and they'll collectively find th
## Documentation
-For more info on how to configure cmux, [head over to our docs](https://cmux.dev/docs/getting-started?utm_source=readme).
+For more info on how to configure cmux, [head over to our docs](https://cmux.com/docs/getting-started?utm_source=readme).
## Keyboard Shortcuts
diff --git a/README.no.md b/README.no.md
index e20ffb3e..2af7e1fc 100644
--- a/README.no.md
+++ b/README.no.md
@@ -23,7 +23,7 @@
- ▶ Demovideo · The Zen of cmux
+ ▶ Demovideo · The Zen of cmux
## Funksjoner
@@ -121,7 +121,7 @@ Gi en million utviklere komponerbare primitiver og de vil kollektivt finne de me
## Dokumentasjon
-For mer informasjon om hvordan du konfigurerer cmux, [gå til dokumentasjonen vår](https://cmux.dev/docs/getting-started?utm_source=readme).
+For mer informasjon om hvordan du konfigurerer cmux, [gå til dokumentasjonen vår](https://cmux.com/docs/getting-started?utm_source=readme).
## Tastatursnarveier
diff --git a/README.pl.md b/README.pl.md
index d159935c..5df81ec3 100644
--- a/README.pl.md
+++ b/README.pl.md
@@ -23,7 +23,7 @@
- ▶ Film demonstracyjny · The Zen of cmux
+ ▶ Film demonstracyjny · The Zen of cmux
## Funkcje
@@ -121,7 +121,7 @@ Daj milionowi programistów kompozycyjne prymitywy, a wspólnie znajdą najefekt
## Dokumentacja
-Więcej informacji o konfiguracji cmux znajdziesz w [naszej dokumentacji](https://cmux.dev/docs/getting-started?utm_source=readme).
+Więcej informacji o konfiguracji cmux znajdziesz w [naszej dokumentacji](https://cmux.com/docs/getting-started?utm_source=readme).
## Skróty Klawiszowe
diff --git a/README.pt-BR.md b/README.pt-BR.md
index c224d56c..71d8ed4c 100644
--- a/README.pt-BR.md
+++ b/README.pt-BR.md
@@ -23,7 +23,7 @@
- ▶ Vídeo de demonstração · O Zen do cmux
+ ▶ Vídeo de demonstração · O Zen do cmux
## Recursos
@@ -121,7 +121,7 @@ Dê a um milhão de desenvolvedores primitivas combináveis e eles coletivamente
## Documentação
-Para mais informações sobre como configurar o cmux, [acesse nossa documentação](https://cmux.dev/docs/getting-started?utm_source=readme).
+Para mais informações sobre como configurar o cmux, [acesse nossa documentação](https://cmux.com/docs/getting-started?utm_source=readme).
## Atalhos de Teclado
diff --git a/README.ru.md b/README.ru.md
index 6aea7f08..c68601ba 100644
--- a/README.ru.md
+++ b/README.ru.md
@@ -23,7 +23,7 @@
- ▶ Демо-видео · The Zen of cmux
+ ▶ Демо-видео · The Zen of cmux
## Возможности
@@ -121,7 +121,7 @@ cmux — это примитив, а не решение. Он даёт вам
## Документация
-Подробнее о настройке cmux читайте в [нашей документации](https://cmux.dev/docs/getting-started?utm_source=readme).
+Подробнее о настройке cmux читайте в [нашей документации](https://cmux.com/docs/getting-started?utm_source=readme).
## Сочетания Клавиш
diff --git a/README.th.md b/README.th.md
index 60bd0451..9d1bf9ad 100644
--- a/README.th.md
+++ b/README.th.md
@@ -23,7 +23,7 @@
- ▶ วิดีโอสาธิต · The Zen of cmux
+ ▶ วิดีโอสาธิต · The Zen of cmux
## คุณสมบัติ
@@ -121,7 +121,7 @@ cmux เป็นส่วนประกอบพื้นฐาน ไม่
## เอกสารประกอบ
-สำหรับข้อมูลเพิ่มเติมเกี่ยวกับการตั้งค่า cmux, [ไปที่เอกสารของเรา](https://cmux.dev/docs/getting-started?utm_source=readme)
+สำหรับข้อมูลเพิ่มเติมเกี่ยวกับการตั้งค่า cmux, [ไปที่เอกสารของเรา](https://cmux.com/docs/getting-started?utm_source=readme)
## ปุ่มลัด
diff --git a/README.tr.md b/README.tr.md
index f07348f9..af8ba9a1 100644
--- a/README.tr.md
+++ b/README.tr.md
@@ -23,7 +23,7 @@
- ▶ Demo videosu · The Zen of cmux
+ ▶ Demo videosu · The Zen of cmux
## Özellikler
@@ -121,7 +121,7 @@ Bir milyon geliştiriciye birleştirilebilir ilkel yapılar verin, en verimli i
## Dokümantasyon
-cmux'u nasıl yapılandıracağınız hakkında daha fazla bilgi için, [dokümantasyonumuza gidin](https://cmux.dev/docs/getting-started?utm_source=readme).
+cmux'u nasıl yapılandıracağınız hakkında daha fazla bilgi için, [dokümantasyonumuza gidin](https://cmux.com/docs/getting-started?utm_source=readme).
## Klavye Kısayolları
diff --git a/README.vi.md b/README.vi.md
index ba04b133..c3f2eb62 100644
--- a/README.vi.md
+++ b/README.vi.md
@@ -21,7 +21,7 @@
- ▶ Video demo · Thiền của cmux
+ ▶ Video demo · Thiền của cmux
## Tính năng
@@ -119,7 +119,7 @@ Trao cho một triệu developer những nguyên thủy có thể ghép, và h
## Tài liệu
-Để biết thêm về cách cấu hình cmux, [xem tài liệu của chúng tôi](https://cmux.dev/docs/getting-started?utm_source=readme).
+Để biết thêm về cách cấu hình cmux, [xem tài liệu của chúng tôi](https://cmux.com/docs/getting-started?utm_source=readme).
## Phím tắt
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 162e21a9..e45d71d0 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -23,7 +23,7 @@
- ▶ 演示视频 · The Zen of cmux
+ ▶ 演示视频 · The Zen of cmux
## 功能特性
@@ -121,7 +121,7 @@ cmux 是原语,而非解决方案。它提供终端、浏览器、通知、工
## 文档
-有关 cmux 配置的更多信息,请[查看我们的文档](https://cmux.dev/docs/getting-started?utm_source=readme)。
+有关 cmux 配置的更多信息,请[查看我们的文档](https://cmux.com/docs/getting-started?utm_source=readme)。
## 键盘快捷键
diff --git a/README.zh-TW.md b/README.zh-TW.md
index eca022d7..4f84d175 100644
--- a/README.zh-TW.md
+++ b/README.zh-TW.md
@@ -23,7 +23,7 @@
- ▶ 示範影片 · The Zen of cmux
+ ▶ 示範影片 · The Zen of cmux
## 功能特色
@@ -121,7 +121,7 @@ cmux 是一個基礎元件,而非完整方案。它提供終端機、瀏覽器
## 文件
-如需更多 cmux 設定資訊,[請前往我們的文件](https://cmux.dev/docs/getting-started?utm_source=readme)。
+如需更多 cmux 設定資訊,[請前往我們的文件](https://cmux.com/docs/getting-started?utm_source=readme)。
## 鍵盤快捷鍵
diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift
index 4d195ac2..d0e55c19 100644
--- a/Sources/ContentView.swift
+++ b/Sources/ContentView.swift
@@ -8308,7 +8308,7 @@ enum DevBuildBannerDebugSettings {
private enum FeedbackComposerSettings {
static let storedEmailKey = "sidebarHelpFeedbackEmail"
static let endpointEnvironmentKey = "CMUX_FEEDBACK_API_URL"
- static let defaultEndpoint = "https://www.cmux.dev/api/feedback"
+ static let defaultEndpoint = "https://cmux.com/api/feedback"
static let foundersEmail = "founders@manaflow.com"
static let maxMessageLength = 4_000
static let maxAttachmentCount = 10
@@ -9755,8 +9755,8 @@ enum FeedbackComposerBridge {
}
private struct SidebarHelpMenuButton: View {
- private let docsURL = URL(string: "https://cmux.dev/docs")
- private let changelogURL = URL(string: "https://cmux.dev/docs/changelog")
+ private let docsURL = URL(string: "https://cmux.com/docs")
+ private let changelogURL = URL(string: "https://cmux.com/docs/changelog")
private let githubURL = URL(string: "https://github.com/manaflow-ai/cmux")
private let githubIssuesURL = URL(string: "https://github.com/manaflow-ai/cmux/issues")
private let discordURL = URL(string: "https://discord.gg/xsgFEVrWCZ")
diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift
index b3be866c..48a7de8d 100644
--- a/Sources/cmuxApp.swift
+++ b/Sources/cmuxApp.swift
@@ -2598,7 +2598,7 @@ private struct AboutPanelView: View {
@Environment(\.openURL) private var openURL
private let githubURL = URL(string: "https://github.com/manaflow-ai/cmux")
- private let docsURL = URL(string: "https://cmux.dev/docs")
+ private let docsURL = URL(string: "https://cmux.com/docs")
private var version: String? { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String }
private var build: String? { Bundle.main.infoDictionary?["CFBundleVersion"] as? String }
diff --git a/web/app/[locale]/posthog.tsx b/web/app/[locale]/posthog.tsx
index 8c924c3c..985550ba 100644
--- a/web/app/[locale]/posthog.tsx
+++ b/web/app/[locale]/posthog.tsx
@@ -7,7 +7,7 @@ import { useEffect, Suspense } from "react";
if (typeof window !== "undefined") {
posthog.init("phc_opOVu7oFzR9wD3I6ZahFGOV2h3mqGpl5EHyQvmHciDP", {
- api_host: "https://r.cmux.dev",
+ api_host: "https://r.cmux.com",
ui_host: "https://us.posthog.com",
person_profiles: "identified_only",
capture_pageview: false,
From cabd5b8a0042a3c3d7d04c2f5c9b7389e5db86fb Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Wed, 18 Mar 2026 01:34:45 -0700
Subject: [PATCH 10/24] Revert testimonial quotes back to cmux.dev (#1722)
These are user-generated quotes that should preserve the original
wording. The domain migration should not rewrite what users said.
Co-authored-by: Lawrence Chen
---
web/messages/ar.json | 2 +-
web/messages/bs.json | 2 +-
web/messages/da.json | 2 +-
web/messages/de.json | 2 +-
web/messages/en.json | 2 +-
web/messages/es.json | 2 +-
web/messages/fr.json | 2 +-
web/messages/it.json | 2 +-
web/messages/ja.json | 2 +-
web/messages/km.json | 2 +-
web/messages/ko.json | 2 +-
web/messages/no.json | 2 +-
web/messages/pl.json | 2 +-
web/messages/pt-BR.json | 2 +-
web/messages/ru.json | 2 +-
web/messages/th.json | 2 +-
web/messages/tr.json | 2 +-
web/messages/zh-CN.json | 2 +-
web/messages/zh-TW.json | 2 +-
19 files changed, 19 insertions(+), 19 deletions(-)
diff --git a/web/messages/ar.json b/web/messages/ar.json
index d8a65e1c..099b3578 100644
--- a/web/messages/ar.json
+++ b/web/messages/ar.json
@@ -575,7 +575,7 @@
"afruth": "أعجبني، استخدمته في اليوم الماضي على ثلاثة مشاريع متوازية كل منها بعدة worktrees. استخدامه مع lazygit وyazi / nvim جعلني أكثر إنتاجية قليلاً من المعتاد دون الحاجة لملاحقة عدة نسخ من ghostty / iTerm. كما يبدو أكثر طبيعية من tmux.",
"northprint": "جربت cmux لأنه بدا جيداً، وهو جيد فعلاً",
"indykish": "cmux جيد جداً.",
- "kataring": "انتقلت إلى cmux.com",
+ "kataring": "انتقلت إلى cmux.dev",
"scottw": "كان هذا اكتشافاً مفيداً جداً. لا أستطيع التوصية به بما فيه الكفاية.",
"johnblythe": "حصلت عليه في عطلة نهاية الأسبوع وأحببته. كنت أنتظر شيئاً كهذا.",
"bchris91": "هذا بالضبط ما أردته. عمل مذهل، شكراً لكم!",
diff --git a/web/messages/bs.json b/web/messages/bs.json
index d68f4cec..6210c4b9 100644
--- a/web/messages/bs.json
+++ b/web/messages/bs.json
@@ -575,7 +575,7 @@
"afruth": "Sviđa mi se, koristio sam ga proteklog dana na tri paralelna projekta, svaki sa nekoliko worktree-ova. Imati ovo u paru sa lazygit-om i yazi / nvim-om učinilo me malo produktivnijim nego obično bez jurnjave za više ghostty / iTerm instanci. Takođe se osjeća prirodnije od tmux-a.",
"northprint": "Probao sam cmux jer je izgledao dobro — dobar je",
"indykish": "cmux je prilično dobar.",
- "kataring": "Prešao sam na cmux.com",
+ "kataring": "Prešao sam na cmux.dev",
"scottw": "Ovo je bio tako koristan nalaz. Ne mogu ga dovoljno preporučiti.",
"johnblythe": "uzeo ovo tokom vikenda i volio sam ga. čekao sam nešto ovako.",
"bchris91": "Ovo je tačno ono što sam htio. Nevjerovatan posao, hvala!",
diff --git a/web/messages/da.json b/web/messages/da.json
index 7481fefe..eeaf4a93 100644
--- a/web/messages/da.json
+++ b/web/messages/da.json
@@ -575,7 +575,7 @@
"afruth": "Jeg kan lide det, kørte det den seneste dag på tre parallelle projekter hver med flere worktrees. At have det parret med lazygit og yazi / nvim gjorde mig lidt mere produktiv end normalt uden at skulle jage flere ghostty / iTerm-instanser. Føles også mere naturligt end tmux.",
"northprint": "Prøvede cmux da det så godt ud — det er godt",
"indykish": "cmux er ret godt.",
- "kataring": "Skiftede til cmux.com",
+ "kataring": "Skiftede til cmux.dev",
"scottw": "Det har været sådan et nyttigt fund. Jeg kan ikke anbefale det nok.",
"johnblythe": "hentede det i weekenden og elskede det. har ventet på noget som dette.",
"bchris91": "Det er præcis hvad jeg har ønsket mig. Fantastisk arbejde, tak!",
diff --git a/web/messages/de.json b/web/messages/de.json
index 67f5f51b..df68123f 100644
--- a/web/messages/de.json
+++ b/web/messages/de.json
@@ -575,7 +575,7 @@
"afruth": "Gefällt mir. Habe es gestern mit drei parallelen Projekten laufen lassen, jedes mit mehreren Worktrees. In Kombination mit lazygit und yazi/nvim war ich etwas produktiver als sonst, ohne mehreren ghostty/iTerm-Instanzen hinterherjagen zu müssen. Fühlt sich auch natürlicher an als tmux.",
"northprint": "cmux ausprobiert, weil es gut aussah, und es ist gut",
"indykish": "cmux ist ziemlich gut.",
- "kataring": "Zu cmux.com gewechselt",
+ "kataring": "Zu cmux.dev gewechselt",
"scottw": "Das war so ein nützlicher Fund. Ich kann es nur weiterempfehlen.",
"johnblythe": "Am Wochenende installiert und sofort begeistert. Habe auf sowas gewartet.",
"bchris91": "Genau das, was ich wollte. Tolle Arbeit, danke!",
diff --git a/web/messages/en.json b/web/messages/en.json
index b7cd095f..44437938 100644
--- a/web/messages/en.json
+++ b/web/messages/en.json
@@ -577,7 +577,7 @@
"afruth": "I like it, ran it in the past day on three parallel projects each with several worktrees. Having this paired with lazygit and yazi / nvim made me a bit more productive than usual without having to chase multiple ghostty / iTerm instances. Also feels more natural than tmux.",
"northprint": "Tried cmux since it looked good — it's good",
"indykish": "cmux is pretty good.",
- "kataring": "Switched to cmux.com",
+ "kataring": "Switched to cmux.dev",
"scottw": "This has been such a useful find. I can't recommend it enough.",
"johnblythe": "grabbed this over the weekend and loved it. been waiting for something like this.",
"bchris91": "This is exactly what I've wanted. Amazing job thank you!",
diff --git a/web/messages/es.json b/web/messages/es.json
index 0c131b4b..6913eaaa 100644
--- a/web/messages/es.json
+++ b/web/messages/es.json
@@ -575,7 +575,7 @@
"afruth": "Me gusta. Ayer lo use con tres proyectos en paralelo, cada uno con varios worktrees. Combinado con lazygit y yazi/nvim me hizo un poco mas productivo de lo habitual sin tener que perseguir multiples instancias de ghostty/iTerm. Tambien se siente mas natural que tmux.",
"northprint": "Probe cmux porque se veia bien, y es bueno",
"indykish": "cmux esta bastante bien.",
- "kataring": "Me cambie a cmux.com",
+ "kataring": "Me cambie a cmux.dev",
"scottw": "Ha sido un hallazgo muy útil. No puedo recomendarlo suficiente.",
"johnblythe": "Lo instalé el fin de semana y me encantó. Estuve esperando algo así.",
"bchris91": "Es exactamente lo que quería. ¡Increíble trabajo, gracias!",
diff --git a/web/messages/fr.json b/web/messages/fr.json
index 51e275d3..61426dd9 100644
--- a/web/messages/fr.json
+++ b/web/messages/fr.json
@@ -575,7 +575,7 @@
"afruth": "J'aime bien. Je l'ai utilise hier sur trois projets en parallele, chacun avec plusieurs worktrees. En combinaison avec lazygit et yazi/nvim, j'etais un peu plus productif que d'habitude sans avoir a jongler entre plusieurs instances ghostty/iTerm. Ca semble aussi plus naturel que tmux.",
"northprint": "J'ai essaye cmux parce que ca avait l'air bien, et c'est bien",
"indykish": "cmux est plutot bon.",
- "kataring": "Je suis passe a cmux.com",
+ "kataring": "Je suis passe a cmux.dev",
"scottw": "C'est une decouverte tellement utile. Je ne peux que le recommander.",
"johnblythe": "Installe ce week-end et j'adore. J'attendais quelque chose comme ca.",
"bchris91": "C'est exactement ce que je voulais. Travail incroyable, merci !",
diff --git a/web/messages/it.json b/web/messages/it.json
index 9b013557..b002fd81 100644
--- a/web/messages/it.json
+++ b/web/messages/it.json
@@ -575,7 +575,7 @@
"afruth": "Mi piace, l'ho usato nell'ultimo giorno su tre progetti paralleli, ciascuno con diversi worktree. Averlo insieme a lazygit e yazi / nvim mi ha reso un po' più produttivo del solito senza dover rincorrere più istanze di ghostty / iTerm. Inoltre sembra più naturale di tmux.",
"northprint": "Ho provato cmux perché sembrava buono, ed è buono",
"indykish": "cmux è piuttosto buono.",
- "kataring": "Sono passato a cmux.com",
+ "kataring": "Sono passato a cmux.dev",
"scottw": "È stata una scoperta così utile. Non lo consiglio mai abbastanza.",
"johnblythe": "l'ho preso nel weekend e mi è piaciuto. aspettavo qualcosa del genere.",
"bchris91": "Questo è esattamente quello che volevo. Lavoro fantastico, grazie!",
diff --git a/web/messages/ja.json b/web/messages/ja.json
index d0371225..ea5d4351 100644
--- a/web/messages/ja.json
+++ b/web/messages/ja.json
@@ -575,7 +575,7 @@
"afruth": "気に入った。昨日3つのプロジェクトをそれぞれ複数のworktreeで並行作業した。lazygitやyazi/nvimと組み合わせると、複数のghostty/iTermインスタンスを追いかけなくても普段より生産性が上がった。tmuxより自然な感じ。",
"northprint": "cmux良さそうなので入れてみたけれど、良い",
"indykish": "cmux、かなりいい。",
- "kataring": "cmux.com に乗り換えた",
+ "kataring": "cmux.dev に乗り換えた",
"scottw": "本当に便利な発見だった。みんなにおすすめしたい。",
"johnblythe": "週末に入れて気に入った。こういうのをずっと待ってた。",
"bchris91": "まさに欲しかったもの。最高です、ありがとう!",
diff --git a/web/messages/km.json b/web/messages/km.json
index 050f848c..128e2142 100644
--- a/web/messages/km.json
+++ b/web/messages/km.json
@@ -571,7 +571,7 @@
"afruth": "ខ្ញុំចូលចិត្តវា ប្រើក្នុងថ្ងៃកន្លងមកជាមួយគម្រោងបីស្របគ្នា នីមួយៗមាន worktree ជាច្រើន។ ការមានវាជាមួយ lazygit និង yazi / nvim ធ្វើឱ្យខ្ញុំផលិតភាពជាងធម្មតាដោយមិនចាំបាច់តាមរក ghostty / iTerm ជាច្រើនវិន្ដូ។ ក៏មានអារម្មណ៍ធម្មជាតិជាង tmux ដែរ។",
"northprint": "សាកល្បង cmux ព្រោះវាមើលទៅល្អ — វាល្អពិតមែន",
"indykish": "cmux ល្អដែរ។",
- "kataring": "ប្ដូរមកប្រើ cmux.com",
+ "kataring": "ប្ដូរមកប្រើ cmux.dev",
"scottw": "នេះជាការរកឃើញដ៏មានប្រយោជន៍។ ខ្ញុំណែនាំវាខ្លាំងណាស់។",
"johnblythe": "ទាញយកមកចុងសប្តាហ៍កន្លងមក ហើយចូលចិត្តវា។ រង់ចាំអ្វីបែបនេះយូរហើយ។",
"bchris91": "នេះជាអ្វីដែលខ្ញុំចង់បានពិតប្រាកដ។ ការងារអស្ចារ្យ អរគុណ!",
diff --git a/web/messages/ko.json b/web/messages/ko.json
index 8d4560ee..156534fd 100644
--- a/web/messages/ko.json
+++ b/web/messages/ko.json
@@ -575,7 +575,7 @@
"afruth": "마음에 들어요. 어제 세 개 프로젝트를 각각 여러 worktree로 병렬 작업했는데, lazygit이랑 yazi/nvim이랑 같이 쓰니까 여러 ghostty/iTerm 인스턴스를 쫓아다닐 필요 없이 평소보다 생산성이 올랐어요. tmux보다 자연스러운 느낌이에요.",
"northprint": "cmux 좋아 보여서 써봤는데, 좋다",
"indykish": "cmux 꽤 괜찮네요.",
- "kataring": "cmux.com로 갈아탔다",
+ "kataring": "cmux.dev로 갈아탔다",
"scottw": "정말 유용한 발견이에요. 충분히 추천할 수 없을 정도로.",
"johnblythe": "주말에 깔아봤는데 좋아요. 이런 걸 기다리고 있었어요.",
"bchris91": "딱 원하던 거예요. 정말 대단해요, 감사합니다!",
diff --git a/web/messages/no.json b/web/messages/no.json
index 310d22eb..b289095e 100644
--- a/web/messages/no.json
+++ b/web/messages/no.json
@@ -575,7 +575,7 @@
"afruth": "Jeg liker det, brukte det i løpet av den siste dagen på tre parallelle prosjekter med flere worktrees. Å ha dette sammen med lazygit og yazi / nvim gjorde meg litt mer produktiv enn vanlig uten å måtte jakte på flere ghostty / iTerm-instanser. Føles også mer naturlig enn tmux.",
"northprint": "Prøvde cmux siden det så bra ut — det er bra",
"indykish": "cmux er ganske bra.",
- "kataring": "Byttet til cmux.com",
+ "kataring": "Byttet til cmux.dev",
"scottw": "Dette har vært et så nyttig funn. Kan ikke anbefale det nok.",
"johnblythe": "lastet dette ned i helgen og elsket det. har ventet på noe slikt.",
"bchris91": "Dette er nøyaktig det jeg har ønsket meg. Fantastisk jobb, takk!",
diff --git a/web/messages/pl.json b/web/messages/pl.json
index a46c137c..bb52ccbf 100644
--- a/web/messages/pl.json
+++ b/web/messages/pl.json
@@ -575,7 +575,7 @@
"afruth": "Podoba mi się, używałem tego ostatniego dnia na trzech równoległych projektach, każdy z kilkoma worktree'ami. Mając to w parze z lazygit i yazi / nvim byłem trochę bardziej produktywny niż zwykle, bez konieczności ścigania wielu instancji ghostty / iTerm. Czuję się też bardziej naturalnie niż tmux.",
"northprint": "Wypróbowałem cmux bo wyglądał dobrze, i jest dobry",
"indykish": "cmux jest całkiem niezły.",
- "kataring": "Przeszedłem na cmux.com",
+ "kataring": "Przeszedłem na cmux.dev",
"scottw": "To było takie przydatne odkrycie. Nie mogę go wystarczająco polecić.",
"johnblythe": "wziąłem to w weekend i pokochałem. czekałem na coś takiego.",
"bchris91": "To jest dokładnie to, czego chciałem. Świetna robota, dziękuję!",
diff --git a/web/messages/pt-BR.json b/web/messages/pt-BR.json
index b5260f4b..8b2b65f3 100644
--- a/web/messages/pt-BR.json
+++ b/web/messages/pt-BR.json
@@ -575,7 +575,7 @@
"afruth": "Gostei, usei no último dia em três projetos paralelos, cada um com vários worktrees. Ter isso junto com lazygit e yazi / nvim me deixou um pouco mais produtivo que o normal sem precisar ficar correndo atrás de várias instâncias de ghostty / iTerm. Também parece mais natural que tmux.",
"northprint": "Experimentei o cmux porque parecia bom — é bom",
"indykish": "cmux é bem bom.",
- "kataring": "Migrei para o cmux.com",
+ "kataring": "Migrei para o cmux.dev",
"scottw": "Essa foi uma descoberta muito útil. Recomendo demais.",
"johnblythe": "baixei no fim de semana e adorei. estava esperando algo assim.",
"bchris91": "Isso é exatamente o que eu queria. Trabalho incrível, obrigado!",
diff --git a/web/messages/ru.json b/web/messages/ru.json
index 01607654..ed2c38c2 100644
--- a/web/messages/ru.json
+++ b/web/messages/ru.json
@@ -575,7 +575,7 @@
"afruth": "Мне нравится, использовал целый день на трёх параллельных проектах, каждый с несколькими worktree. В связке с lazygit и yazi / nvim стал чуть продуктивнее обычного, без необходимости гоняться за несколькими экземплярами ghostty / iTerm. Также ощущается естественнее чем tmux.",
"northprint": "Попробовал cmux потому что выглядел хорошо, и он хорош",
"indykish": "cmux довольно хорош.",
- "kataring": "Перешёл на cmux.com",
+ "kataring": "Перешёл на cmux.dev",
"scottw": "Невероятно полезная находка. Рекомендую всем.",
"johnblythe": "скачал на выходных и полюбил. ждал чего-то подобного.",
"bchris91": "Это именно то, что я хотел. Потрясающая работа, спасибо!",
diff --git a/web/messages/th.json b/web/messages/th.json
index c225b33c..7627ba68 100644
--- a/web/messages/th.json
+++ b/web/messages/th.json
@@ -571,7 +571,7 @@
"afruth": "ชอบเลย ใช้ในวันที่ผ่านมากับสามโปรเจกต์ที่ทำพร้อมกัน แต่ละโปรเจกต์มี worktree หลายอัน การมีสิ่งนี้คู่กับ lazygit และ yazi / nvim ทำให้ผมทำงานได้มากขึ้นกว่าปกติโดยไม่ต้องวิ่งไล่หลายหน้าต่าง ghostty / iTerm รู้สึกเป็นธรรมชาติกว่า tmux ด้วย",
"northprint": "ลอง cmux เพราะมันดูดี — มันดีจริง",
"indykish": "cmux ดีนะ",
- "kataring": "ย้ายมาใช้ cmux.com แล้ว",
+ "kataring": "ย้ายมาใช้ cmux.dev แล้ว",
"scottw": "นี่เป็นการค้นพบที่มีประโยชน์มาก แนะนำเลย",
"johnblythe": "โหลดมาเมื่อสุดสัปดาห์แล้วชอบมาก รอของแบบนี้มานานแล้ว",
"bchris91": "นี่คือสิ่งที่ผมต้องการเป๊ะเลย ทำได้ยอดเยี่ยม ขอบคุณ!",
diff --git a/web/messages/tr.json b/web/messages/tr.json
index c6eff9ff..cd6c4da0 100644
--- a/web/messages/tr.json
+++ b/web/messages/tr.json
@@ -575,7 +575,7 @@
"afruth": "Beğendim, son gün içinde her biri birden fazla worktree ile üç paralel projede kullandım. Bunu lazygit ve yazi / nvim ile birlikte kullanmak, birden fazla ghostty / iTerm örneğini kovalamak zorunda kalmadan normalden biraz daha üretken olmamı sağladı. tmux'tan daha doğal hissettiriyor.",
"northprint": "cmux'u iyi göründüğü için denedim — gerçekten iyi",
"indykish": "cmux oldukça iyi.",
- "kataring": "cmux.com'e geçtim",
+ "kataring": "cmux.dev'e geçtim",
"scottw": "Bu çok faydalı bir keşif oldu. Yeterince tavsiye edemem.",
"johnblythe": "hafta sonu indirdim ve bayıldım. böyle bir şey bekliyordum.",
"bchris91": "Bu tam olarak istediğim şey. Harika iş, teşekkürler!",
diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json
index e2250795..e970d95d 100644
--- a/web/messages/zh-CN.json
+++ b/web/messages/zh-CN.json
@@ -575,7 +575,7 @@
"afruth": "很喜欢,过去一天在三个并行项目上用了,每个都有多个 worktree。配合 lazygit 和 yazi/nvim,比以前高效多了,不用在多个 Ghostty/iTerm 实例之间切换。比 tmux 也更自然。",
"northprint": "试了 cmux,确实不错",
"indykish": "cmux 挺好用的。",
- "kataring": "换到 cmux.com 了",
+ "kataring": "换到 cmux.dev 了",
"scottw": "非常有用的发现。强烈推荐。",
"johnblythe": "周末用了,很喜欢。一直在等这样的工具。",
"bchris91": "这正是我想要的。做得太好了,谢谢!",
diff --git a/web/messages/zh-TW.json b/web/messages/zh-TW.json
index 89fce352..c6136785 100644
--- a/web/messages/zh-TW.json
+++ b/web/messages/zh-TW.json
@@ -575,7 +575,7 @@
"afruth": "很喜歡,過去一天在三個並行專案上用了,每個都有多個 worktree。搭配 lazygit 和 yazi/nvim,比以前高效多了,不用在多個 Ghostty/iTerm 執行個體之間切換。比 tmux 也更自然。",
"northprint": "試了 cmux,確實不錯",
"indykish": "cmux 蠻好用的。",
- "kataring": "換到 cmux.com 了",
+ "kataring": "換到 cmux.dev 了",
"scottw": "非常有用的發現。強烈推薦。",
"johnblythe": "週末用了,很喜歡。一直在等這樣的工具。",
"bchris91": "這正是我想要的。做得太好了,謝謝!",
From c1543ea49ab19fa63d5fcf53b9b6c8222e5e3223 Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Wed, 18 Mar 2026 01:39:12 -0700
Subject: [PATCH 11/24] Fix floating portal terminal after nightly update
relaunch (#1696)
* test: add background-prime visibility regression
* fix: hide background-primed workspaces from portals
---
Sources/ContentView.swift | 56 ++++++++++++++-----
.../WorkspaceContentViewVisibilityTests.swift | 30 ++++++++++
2 files changed, 72 insertions(+), 14 deletions(-)
diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift
index d0e55c19..181d1d0e 100644
--- a/Sources/ContentView.swift
+++ b/Sources/ContentView.swift
@@ -1320,6 +1320,40 @@ enum WorkspaceMountPolicy {
}
}
+struct MountedWorkspacePresentation: Equatable {
+ let isRenderedVisible: Bool
+ let isPanelVisible: Bool
+ let renderOpacity: Double
+}
+
+enum MountedWorkspacePresentationPolicy {
+ static func resolve(
+ isSelectedWorkspace: Bool,
+ isRetiringWorkspace: Bool,
+ shouldPrimeInBackground: Bool
+ ) -> MountedWorkspacePresentation {
+ let isRenderedVisible = isSelectedWorkspace || isRetiringWorkspace
+ let renderOpacity: Double = {
+ if isRenderedVisible {
+ return 1
+ }
+ if shouldPrimeInBackground {
+ // Keep the workspace mounted long enough to warm the terminal surface, but do
+ // not mark it panel-visible. Visible portal entries intentionally survive
+ // transient anchor loss during bonsplit drag/reparent churn.
+ return 0.001
+ }
+ return 0
+ }()
+
+ return MountedWorkspacePresentation(
+ isRenderedVisible: isRenderedVisible,
+ isPanelVisible: isRenderedVisible,
+ renderOpacity: renderOpacity
+ )
+ }
+}
+
/// Installs a FileDropOverlayView on the window's theme frame for Finder file drag support.
func installFileDropOverlay(on window: NSWindow, tabManager: TabManager) {
guard objc_getAssociatedObject(window, &fileDropOverlayKey) == nil,
@@ -2014,17 +2048,11 @@ struct ContentView: View {
let isSelectedWorkspace = selectedWorkspaceId == tab.id
let isRetiringWorkspace = retiringWorkspaceId == tab.id
let shouldPrimeInBackground = tabManager.pendingBackgroundWorkspaceLoadIds.contains(tab.id)
- let isRenderedVisible = isSelectedWorkspace || isRetiringWorkspace
- let isWorkspaceVisibleToPanels = isRenderedVisible || shouldPrimeInBackground
- let workspaceRenderOpacity: Double = {
- if isRenderedVisible {
- return 1
- }
- if shouldPrimeInBackground {
- return 0.001
- }
- return 0
- }()
+ let presentation = MountedWorkspacePresentationPolicy.resolve(
+ isSelectedWorkspace: isSelectedWorkspace,
+ isRetiringWorkspace: isRetiringWorkspace,
+ shouldPrimeInBackground: shouldPrimeInBackground
+ )
// Keep the retiring workspace visible during handoff, but never input-active.
// Allowing both selected+retiring workspaces to be input-active lets the
// old workspace steal first responder (notably with WKWebView), which can
@@ -2033,7 +2061,7 @@ struct ContentView: View {
let portalPriority = isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0)
WorkspaceContentView(
workspace: tab,
- isWorkspaceVisible: isWorkspaceVisibleToPanels,
+ isWorkspaceVisible: presentation.isPanelVisible,
isWorkspaceInputActive: isInputActive,
workspacePortalPriority: portalPriority,
onThemeRefreshRequest: { reason, eventId, source, payloadHex in
@@ -2046,9 +2074,9 @@ struct ContentView: View {
)
}
)
- .opacity(workspaceRenderOpacity)
+ .opacity(presentation.renderOpacity)
.allowsHitTesting(isSelectedWorkspace)
- .accessibilityHidden(!isRenderedVisible)
+ .accessibilityHidden(!presentation.isRenderedVisible)
.zIndex(isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0))
.task(id: shouldPrimeInBackground ? tab.id : nil) {
await primeBackgroundWorkspaceIfNeeded(workspaceId: tab.id)
diff --git a/cmuxTests/WorkspaceContentViewVisibilityTests.swift b/cmuxTests/WorkspaceContentViewVisibilityTests.swift
index 6e8d62e3..d3759a0c 100644
--- a/cmuxTests/WorkspaceContentViewVisibilityTests.swift
+++ b/cmuxTests/WorkspaceContentViewVisibilityTests.swift
@@ -7,6 +7,36 @@ import XCTest
#endif
final class WorkspaceContentViewVisibilityTests: XCTestCase {
+ func testBackgroundPrimedWorkspaceStaysMountedButNotPanelVisible() {
+ XCTAssertEqual(
+ MountedWorkspacePresentationPolicy.resolve(
+ isSelectedWorkspace: false,
+ isRetiringWorkspace: false,
+ shouldPrimeInBackground: true
+ ),
+ MountedWorkspacePresentation(
+ isRenderedVisible: false,
+ isPanelVisible: false,
+ renderOpacity: 0.001
+ )
+ )
+ }
+
+ func testRetiringWorkspaceStaysPanelVisibleDuringHandoff() {
+ XCTAssertEqual(
+ MountedWorkspacePresentationPolicy.resolve(
+ isSelectedWorkspace: false,
+ isRetiringWorkspace: true,
+ shouldPrimeInBackground: false
+ ),
+ MountedWorkspacePresentation(
+ isRenderedVisible: true,
+ isPanelVisible: true,
+ renderOpacity: 1
+ )
+ )
+ }
+
func testPanelVisibleInUIReturnsFalseWhenWorkspaceHidden() {
XCTAssertFalse(
WorkspaceContentView.panelVisibleInUI(
From 2f08e1bee01b9782feef648ceb42cf9f2934db51 Mon Sep 17 00:00:00 2001
From: Austin Wang
Date: Wed, 18 Mar 2026 01:56:36 -0700
Subject: [PATCH 12/24] Revert "fix: repair NIGHTLY Sparkle quarantine metadata
(#1703)" (#1725)
This reverts commit 629b63dfb865fa53f091842b9e8423da1132db81.
---
GhosttyTabs.xcodeproj/project.pbxproj | 10 +-
Sources/Update/UpdateDelegate.swift | 21 --
Sources/Update/UpdateDriver.swift | 27 --
Sources/Update/UpdateQuarantineRepair.swift | 292 --------------------
cmuxTests/UpdateQuarantineRepairTests.swift | 173 ------------
5 files changed, 1 insertion(+), 522 deletions(-)
delete mode 100644 Sources/Update/UpdateQuarantineRepair.swift
delete mode 100644 cmuxTests/UpdateQuarantineRepairTests.swift
diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj
index dac5549c..be770cf8 100644
--- a/GhosttyTabs.xcodeproj/project.pbxproj
+++ b/GhosttyTabs.xcodeproj/project.pbxproj
@@ -99,9 +99,7 @@
F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */; };
FA000000A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */; };
A5008381 /* BrowserFindJavaScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008380 /* BrowserFindJavaScriptTests.swift */; };
- A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008382 /* CommandPaletteSearchEngineTests.swift */; };
- AB169902A1B2C3D4E5F60718 /* UpdateQuarantineRepairTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB169903A1B2C3D4E5F60718 /* UpdateQuarantineRepairTests.swift */; };
- AB169900A1B2C3D4E5F60718 /* UpdateQuarantineRepair.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB169901A1B2C3D4E5F60718 /* UpdateQuarantineRepair.swift */; };
+ A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008382 /* CommandPaletteSearchEngineTests.swift */; };
DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000001 /* Localizable.xcstrings */; };
DA7A10CA710E000000000004 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000002 /* InfoPlist.xcstrings */; };
A5001623 /* cmux.sdef in Resources */ = {isa = PBXBuildFile; fileRef = A5001622 /* cmux.sdef */; };
@@ -267,8 +265,6 @@
FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStressProfileTests.swift; sourceTree = ""; };
A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = ""; };
A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = ""; };
- AB169903A1B2C3D4E5F60718 /* UpdateQuarantineRepairTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateQuarantineRepairTests.swift; sourceTree = ""; };
- AB169901A1B2C3D4E5F60718 /* UpdateQuarantineRepair.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateQuarantineRepair.swift; sourceTree = ""; };
DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; };
DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; };
A5001622 /* cmux.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = cmux.sdef; sourceTree = ""; };
@@ -444,7 +440,6 @@
A5001221 /* UpdateTestSupport.swift */,
A5001224 /* UpdateTestURLProtocol.swift */,
A5001223 /* UpdateLogStore.swift */,
- AB169901A1B2C3D4E5F60718 /* UpdateQuarantineRepair.swift */,
A5001217 /* UpdatePopoverView.swift */,
A5001218 /* UpdateTitlebarAccessory.swift */,
A5001219 /* WindowToolbarController.swift */,
@@ -524,7 +519,6 @@
FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */,
A5008380 /* BrowserFindJavaScriptTests.swift */,
A5008382 /* CommandPaletteSearchEngineTests.swift */,
- AB169903A1B2C3D4E5F60718 /* UpdateQuarantineRepairTests.swift */,
970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */,
58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */,
02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */,
@@ -737,7 +731,6 @@
A500120B /* UpdateTestSupport.swift in Sources */,
A500120E /* UpdateTestURLProtocol.swift in Sources */,
A500120D /* UpdateLogStore.swift in Sources */,
- AB169900A1B2C3D4E5F60718 /* UpdateQuarantineRepair.swift in Sources */,
A5001207 /* UpdatePopoverView.swift in Sources */,
A5001208 /* UpdateTitlebarAccessory.swift in Sources */,
A5001209 /* WindowToolbarController.swift in Sources */,
@@ -785,7 +778,6 @@
FA000000A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift in Sources */,
A5008381 /* BrowserFindJavaScriptTests.swift in Sources */,
A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */,
- AB169902A1B2C3D4E5F60718 /* UpdateQuarantineRepairTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
diff --git a/Sources/Update/UpdateDelegate.swift b/Sources/Update/UpdateDelegate.swift
index 2fb1cd5e..7de114d3 100644
--- a/Sources/Update/UpdateDelegate.swift
+++ b/Sources/Update/UpdateDelegate.swift
@@ -79,20 +79,6 @@ extension UpdateDriver: SPUUpdaterDelegate {
}
}
- func updater(_ updater: SPUUpdater, willExtractUpdate item: SUAppcastItem) {
- prepareQuarantineRepair(for: item.fileURL)
- do {
- let result = try UpdateQuarantineRepair.repairDownloadedArchiveIfNeeded(
- hostName: UpdateQuarantineRepair.sparkleHostName(),
- versionString: item.versionString,
- dataURL: item.fileURL
- )
- logUpdateQuarantineRepair(stage: "download", result: result)
- } catch {
- UpdateLogStore.shared.append("quarantine repair download failed: \(error.localizedDescription)")
- }
- }
-
func updaterDidNotFindUpdate(_ updater: SPUUpdater, error: Error) {
viewModel.clearDetectedUpdate()
let nsError = error as NSError
@@ -125,13 +111,6 @@ extension UpdateDriver: SPUUpdaterDelegate {
}
}
-private func logUpdateQuarantineRepair(stage: String, result: UpdateQuarantineRepairResult) {
- let path = result.url?.path ?? ""
- let before = result.beforeRawValue ?? ""
- let after = result.afterRawValue ?? ""
- UpdateLogStore.shared.append("quarantine repair \(stage): \(result.outcome) path=\(path) before=\(before) after=\(after)")
-}
-
private func describeNoUpdateFoundReason(_ reason: SPUNoUpdateFoundReason) -> String {
switch reason {
case .unknown:
diff --git a/Sources/Update/UpdateDriver.swift b/Sources/Update/UpdateDriver.swift
index fc81b6ba..289df890 100644
--- a/Sources/Update/UpdateDriver.swift
+++ b/Sources/Update/UpdateDriver.swift
@@ -9,8 +9,6 @@ class UpdateDriver: NSObject, SPUUserDriver {
private var pendingCheckTransition: DispatchWorkItem?
private var checkTimeoutWorkItem: DispatchWorkItem?
private var lastFeedURLString: String?
- private var updateFileURLForQuarantineRepair: URL?
- private var finishedExtractedUpdateQuarantineRepair: Bool = false
init(viewModel: UpdateViewModel, hostBundle _: Bundle) {
self.viewModel = viewModel
@@ -120,13 +118,11 @@ class UpdateDriver: NSObject, SPUUserDriver {
func showDownloadDidStartExtractingUpdate() {
UpdateLogStore.shared.append("show extraction started")
setState(.extracting(.init(progress: 0)))
- maybeRepairExtractedUpdateQuarantine()
}
func showExtractionReceivedProgress(_ progress: Double) {
UpdateLogStore.shared.append(String(format: "show extraction progress: %.2f", progress))
setState(.extracting(.init(progress: progress)))
- maybeRepairExtractedUpdateQuarantine()
}
func showReady(toInstallAndRelaunch reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) {
@@ -258,11 +254,6 @@ class UpdateDriver: NSObject, SPUUserDriver {
UpdateLogStore.shared.append("feed url resolved\(suffix): \(feedURLString)")
}
- func prepareQuarantineRepair(for updateFileURL: URL?) {
- updateFileURLForQuarantineRepair = updateFileURL
- finishedExtractedUpdateQuarantineRepair = false
- }
-
func formatErrorForLog(_ error: Error) -> String {
let nsError = error as NSError
var parts: [String] = ["\(nsError.domain)(\(nsError.code))"]
@@ -311,24 +302,6 @@ class UpdateDriver: NSObject, SPUUserDriver {
}
}
- private func maybeRepairExtractedUpdateQuarantine() {
- guard !finishedExtractedUpdateQuarantineRepair else { return }
-
- do {
- let result = try UpdateQuarantineRepair.repairExtractedApplicationIfNeeded(dataURL: updateFileURLForQuarantineRepair)
- guard result.outcome != .notFound else { return }
-
- finishedExtractedUpdateQuarantineRepair = true
- let path = result.url?.path ?? ""
- let before = result.beforeRawValue ?? ""
- let after = result.afterRawValue ?? ""
- UpdateLogStore.shared.append("quarantine repair extracted-app: \(result.outcome) path=\(path) before=\(before) after=\(after)")
- } catch {
- finishedExtractedUpdateQuarantineRepair = true
- UpdateLogStore.shared.append("quarantine repair extracted-app failed: \(error.localizedDescription)")
- }
- }
-
private func runOnMain(_ action: @escaping () -> Void) {
if Thread.isMainThread {
action()
diff --git a/Sources/Update/UpdateQuarantineRepair.swift b/Sources/Update/UpdateQuarantineRepair.swift
deleted file mode 100644
index 423c2981..00000000
--- a/Sources/Update/UpdateQuarantineRepair.swift
+++ /dev/null
@@ -1,292 +0,0 @@
-import CoreServices
-import Darwin
-import Foundation
-
-enum UpdateQuarantineRepairOutcome: Equatable {
- case skipped
- case notFound
- case notQuarantined
- case alreadyValid
- case repaired
-}
-
-struct UpdateQuarantineRepairResult {
- let outcome: UpdateQuarantineRepairOutcome
- let url: URL?
- let beforeRawValue: String?
- let afterRawValue: String?
-}
-
-enum UpdateQuarantineRepair {
- static let sparkleCacheDirectoryName = "org.sparkle-project.Sparkle"
- static let persistentDownloadsDirectoryName = "PersistentDownloads"
- static let installationDirectoryName = "Installation"
-
- private static let quarantineAttributeName = "com.apple.quarantine"
-
- static func sparkleHostName(for bundle: Bundle = .main, fileManager: FileManager = .default) -> String {
- for key in ["SUBundleName", "CFBundleDisplayName", kCFBundleNameKey as String] {
- if let value = bundle.object(forInfoDictionaryKey: key) as? String,
- !value.isEmpty {
- return value
- }
- }
- return (fileManager.displayName(atPath: bundle.bundlePath) as NSString).deletingPathExtension
- }
-
- static func persistentDownloadsRootURL(bundleIdentifier: String, cachesDirectory: URL? = nil) -> URL {
- let base = cachesDirectory ?? FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first ?? FileManager.default.temporaryDirectory
- return base
- .appendingPathComponent(bundleIdentifier, isDirectory: true)
- .appendingPathComponent(sparkleCacheDirectoryName, isDirectory: true)
- .appendingPathComponent(persistentDownloadsDirectoryName, isDirectory: true)
- }
-
- static func installationRootURL(bundleIdentifier: String, cachesDirectory: URL? = nil) -> URL {
- let base = cachesDirectory ?? FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first ?? FileManager.default.temporaryDirectory
- return base
- .appendingPathComponent(bundleIdentifier, isDirectory: true)
- .appendingPathComponent(sparkleCacheDirectoryName, isDirectory: true)
- .appendingPathComponent(installationDirectoryName, isDirectory: true)
- }
-
- static func locateDownloadedArchive(
- bundleIdentifier: String,
- hostName: String,
- versionString: String,
- cachesDirectory: URL? = nil,
- fileManager: FileManager = .default
- ) -> URL? {
- let rootURL = persistentDownloadsRootURL(bundleIdentifier: bundleIdentifier, cachesDirectory: cachesDirectory)
- let expectedDirectoryName = (hostName.isEmpty || versionString.isEmpty) ? nil : "\(hostName) \(versionString)"
-
- if let exactMatch = newestItem(
- in: rootURL,
- fileManager: fileManager,
- skipPackageDescendants: true,
- matching: { url, values, _ in
- guard values.isRegularFile == true else { return false }
- guard let expectedDirectoryName else { return true }
- return url.deletingLastPathComponent().lastPathComponent == expectedDirectoryName
- }
- ) {
- return exactMatch
- }
-
- return newestItem(in: rootURL, fileManager: fileManager, skipPackageDescendants: true) { _, values, _ in
- values.isRegularFile == true
- }
- }
-
- static func locateExtractedApplication(
- bundleIdentifier: String,
- bundleName: String,
- cachesDirectory: URL? = nil,
- fileManager: FileManager = .default
- ) -> URL? {
- let rootURL = installationRootURL(bundleIdentifier: bundleIdentifier, cachesDirectory: cachesDirectory)
- let expectedBundleName = bundleName.isEmpty ? nil : bundleName
-
- if let exactMatch = newestItem(
- in: rootURL,
- fileManager: fileManager,
- skipPackageDescendants: true,
- matching: { url, values, _ in
- guard values.isDirectory == true, url.pathExtension == "app" else { return false }
- guard let expectedBundleName else { return true }
- return url.lastPathComponent == expectedBundleName
- }
- ) {
- return exactMatch
- }
-
- return newestItem(in: rootURL, fileManager: fileManager, skipPackageDescendants: true) { url, values, _ in
- values.isDirectory == true && url.pathExtension == "app"
- }
- }
-
- static func repairDownloadedArchiveIfNeeded(
- hostName: String,
- versionString: String,
- bundle: Bundle = .main,
- fileManager: FileManager = .default,
- cachesDirectory: URL? = nil,
- dataURL: URL? = nil
- ) throws -> UpdateQuarantineRepairResult {
- guard let bundleIdentifier = bundle.bundleIdentifier else {
- return .init(outcome: .skipped, url: nil, beforeRawValue: nil, afterRawValue: nil)
- }
-
- guard let archiveURL = locateDownloadedArchive(
- bundleIdentifier: bundleIdentifier,
- hostName: hostName,
- versionString: versionString,
- cachesDirectory: cachesDirectory,
- fileManager: fileManager
- ) else {
- return .init(outcome: .notFound, url: nil, beforeRawValue: nil, afterRawValue: nil)
- }
-
- return try repairQuarantineIfNeeded(
- at: archiveURL,
- agentBundleIdentifier: bundleIdentifier,
- agentName: sparkleHostName(for: bundle, fileManager: fileManager),
- dataURL: dataURL
- )
- }
-
- static func repairExtractedApplicationIfNeeded(
- bundle: Bundle = .main,
- fileManager: FileManager = .default,
- cachesDirectory: URL? = nil,
- dataURL: URL? = nil
- ) throws -> UpdateQuarantineRepairResult {
- guard let bundleIdentifier = bundle.bundleIdentifier else {
- return .init(outcome: .skipped, url: nil, beforeRawValue: nil, afterRawValue: nil)
- }
-
- guard let appURL = locateExtractedApplication(
- bundleIdentifier: bundleIdentifier,
- bundleName: bundle.bundleURL.lastPathComponent,
- cachesDirectory: cachesDirectory,
- fileManager: fileManager
- ) else {
- return .init(outcome: .notFound, url: nil, beforeRawValue: nil, afterRawValue: nil)
- }
-
- return try repairQuarantineIfNeeded(
- at: appURL,
- agentBundleIdentifier: bundleIdentifier,
- agentName: sparkleHostName(for: bundle, fileManager: fileManager),
- dataURL: dataURL
- )
- }
-
- static func repairQuarantineIfNeeded(
- at url: URL,
- agentBundleIdentifier: String,
- agentName: String,
- dataURL: URL? = nil
- ) throws -> UpdateQuarantineRepairResult {
- let beforeRawValue = rawQuarantineAttribute(at: url)
- var resourceValues = try url.resourceValues(forKeys: [.quarantinePropertiesKey])
- var quarantineProperties = resourceValues.quarantineProperties ?? [:]
-
- let hasQuarantine = beforeRawValue != nil || !quarantineProperties.isEmpty
- guard hasQuarantine else {
- return .init(outcome: .notQuarantined, url: url, beforeRawValue: beforeRawValue, afterRawValue: beforeRawValue)
- }
-
- var didChange = false
-
- let existingBundleIdentifier = (quarantineProperties[kLSQuarantineAgentBundleIdentifierKey as String] as? String)?
- .trimmingCharacters(in: .whitespacesAndNewlines)
- if existingBundleIdentifier != agentBundleIdentifier {
- quarantineProperties[kLSQuarantineAgentBundleIdentifierKey as String] = agentBundleIdentifier
- didChange = true
- }
-
- let existingAgentName = (quarantineProperties[kLSQuarantineAgentNameKey as String] as? String)?
- .trimmingCharacters(in: .whitespacesAndNewlines)
- if existingAgentName != agentName {
- quarantineProperties[kLSQuarantineAgentNameKey as String] = agentName
- didChange = true
- }
-
- if quarantineProperties[kLSQuarantineTypeKey as String] == nil {
- quarantineProperties[kLSQuarantineTypeKey as String] = inferredQuarantineType(for: dataURL)
- didChange = true
- }
-
- if let dataURL, quarantineProperties[kLSQuarantineDataURLKey as String] == nil {
- quarantineProperties[kLSQuarantineDataURLKey as String] = dataURL
- didChange = true
- }
-
- if !didChange, let beforeRawValue, rawQuarantineNeedsLaunchServicesRepair(beforeRawValue) {
- didChange = true
- }
-
- guard didChange else {
- return .init(outcome: .alreadyValid, url: url, beforeRawValue: beforeRawValue, afterRawValue: beforeRawValue)
- }
-
- resourceValues.quarantineProperties = quarantineProperties
- var mutableURL = url
- try mutableURL.setResourceValues(resourceValues)
-
- let afterRawValue = rawQuarantineAttribute(at: url)
- return .init(outcome: .repaired, url: url, beforeRawValue: beforeRawValue, afterRawValue: afterRawValue)
- }
-
- static func rawQuarantineAttribute(at url: URL) -> String? {
- url.path.withCString { pathPointer in
- quarantineAttributeName.withCString { attributePointer in
- let size = getxattr(pathPointer, attributePointer, nil, 0, 0, XATTR_NOFOLLOW)
- guard size >= 0 else { return nil }
-
- var buffer = [UInt8](repeating: 0, count: Int(size))
- let bytesRead = getxattr(pathPointer, attributePointer, &buffer, buffer.count, 0, XATTR_NOFOLLOW)
- guard bytesRead >= 0 else { return nil }
-
- return String(decoding: buffer.prefix(Int(bytesRead)), as: UTF8.self)
- }
- }
- }
-
- static func rawQuarantineNeedsLaunchServicesRepair(_ rawValue: String) -> Bool {
- let components = rawValue.split(separator: ";", omittingEmptySubsequences: false)
- guard components.count >= 4 else { return true }
- return components[3].isEmpty
- }
-
- private static func inferredQuarantineType(for dataURL: URL?) -> String {
- guard let scheme = dataURL?.scheme?.lowercased() else {
- return kLSQuarantineTypeOtherDownload as String
- }
- switch scheme {
- case "http", "https":
- return kLSQuarantineTypeWebDownload as String
- default:
- return kLSQuarantineTypeOtherDownload as String
- }
- }
-
- private static func newestItem(
- in rootURL: URL,
- fileManager: FileManager,
- skipPackageDescendants: Bool,
- matching predicate: (URL, URLResourceValues, FileManager.DirectoryEnumerator) -> Bool
- ) -> URL? {
- guard fileManager.fileExists(atPath: rootURL.path) else { return nil }
-
- let keys: [URLResourceKey] = [.contentModificationDateKey, .isRegularFileKey, .isDirectoryKey]
- guard let enumerator = fileManager.enumerator(
- at: rootURL,
- includingPropertiesForKeys: keys,
- options: [.skipsHiddenFiles],
- errorHandler: nil
- ) else {
- return nil
- }
-
- var newestURL: URL?
- var newestDate = Date.distantPast
-
- for case let candidateURL as URL in enumerator {
- let resourceValues = (try? candidateURL.resourceValues(forKeys: Set(keys))) ?? URLResourceValues()
- if skipPackageDescendants && (candidateURL.pathExtension == "app" || candidateURL.pathExtension == "pkg") {
- enumerator.skipDescendants()
- }
- guard predicate(candidateURL, resourceValues, enumerator) else { continue }
-
- let contentModificationDate = resourceValues.contentModificationDate ?? Date.distantPast
- if newestURL == nil || contentModificationDate > newestDate {
- newestURL = candidateURL
- newestDate = contentModificationDate
- }
- }
-
- return newestURL
- }
-}
diff --git a/cmuxTests/UpdateQuarantineRepairTests.swift b/cmuxTests/UpdateQuarantineRepairTests.swift
deleted file mode 100644
index 04c4e97a..00000000
--- a/cmuxTests/UpdateQuarantineRepairTests.swift
+++ /dev/null
@@ -1,173 +0,0 @@
-import CoreServices
-import Darwin
-import Foundation
-import XCTest
-
-#if canImport(cmux_DEV)
-@testable import cmux_DEV
-#elseif canImport(cmux)
-@testable import cmux
-#endif
-
-final class UpdateQuarantineRepairTests: XCTestCase {
- func testRepairAddsLaunchServicesMetadataForMissingAgentBundleIdentifier() throws {
- let fileURL = try makeTemporaryFile(named: "cmux-nightly.dmg")
- try writeRawQuarantine("0383;69ba4249;;", to: fileURL)
-
- let beforeRawValue = try XCTUnwrap(UpdateQuarantineRepair.rawQuarantineAttribute(at: fileURL))
- XCTAssertEqual(beforeRawValue, "0383;69ba4249;;")
-
- let result = try UpdateQuarantineRepair.repairQuarantineIfNeeded(
- at: fileURL,
- agentBundleIdentifier: "com.cmuxterm.app.nightly",
- agentName: "cmux NIGHTLY",
- dataURL: URL(string: "https://example.com/cmux-nightly-macos.dmg")
- )
-
- XCTAssertEqual(result.outcome, .repaired)
- let afterRawValue = try XCTUnwrap(UpdateQuarantineRepair.rawQuarantineAttribute(at: fileURL))
- XCTAssertNotEqual(afterRawValue, beforeRawValue)
- XCTAssertFalse(UpdateQuarantineRepair.rawQuarantineNeedsLaunchServicesRepair(afterRawValue))
-
- let properties = try fileURL.resourceValues(forKeys: [.quarantinePropertiesKey]).quarantineProperties
- XCTAssertEqual(properties?[kLSQuarantineAgentBundleIdentifierKey as String] as? String, "com.cmuxterm.app.nightly")
- XCTAssertEqual(properties?[kLSQuarantineAgentNameKey as String] as? String, "cmux NIGHTLY")
- XCTAssertEqual(properties?[kLSQuarantineTypeKey as String] as? String, kLSQuarantineTypeWebDownload as String)
- }
-
- func testRepairIsNoOpWhenLaunchServicesQuarantineRecordIsAlreadyValid() throws {
- let fileURL = try makeTemporaryFile(named: "cmux-nightly.dmg")
- try writeRawQuarantine("0383;69ba4249;;", to: fileURL)
-
- _ = try UpdateQuarantineRepair.repairQuarantineIfNeeded(
- at: fileURL,
- agentBundleIdentifier: "com.cmuxterm.app.nightly",
- agentName: "cmux NIGHTLY",
- dataURL: URL(string: "https://example.com/cmux-nightly-macos.dmg")
- )
-
- let repairedRawValue = try XCTUnwrap(UpdateQuarantineRepair.rawQuarantineAttribute(at: fileURL))
- let secondResult = try UpdateQuarantineRepair.repairQuarantineIfNeeded(
- at: fileURL,
- agentBundleIdentifier: "com.cmuxterm.app.nightly",
- agentName: "cmux NIGHTLY",
- dataURL: URL(string: "https://example.com/cmux-nightly-macos.dmg")
- )
-
- XCTAssertEqual(secondResult.outcome, .alreadyValid)
- XCTAssertEqual(secondResult.beforeRawValue, repairedRawValue)
- XCTAssertEqual(secondResult.afterRawValue, repairedRawValue)
- }
-
- func testLocateDownloadedArchivePrefersNewestMatchingVersionDirectory() throws {
- let cachesDirectory = try makeTemporaryDirectory(named: "SparkleCaches")
- let rootURL = UpdateQuarantineRepair.persistentDownloadsRootURL(
- bundleIdentifier: "com.cmuxterm.app.nightly",
- cachesDirectory: cachesDirectory
- )
-
- let oldArchiveURL = rootURL
- .appendingPathComponent("token-old", isDirectory: true)
- .appendingPathComponent("cmux NIGHTLY 1234", isDirectory: true)
- .appendingPathComponent("old.dmg")
- let newArchiveURL = rootURL
- .appendingPathComponent("token-new", isDirectory: true)
- .appendingPathComponent("cmux NIGHTLY 1234", isDirectory: true)
- .appendingPathComponent("new.dmg")
- let otherArchiveURL = rootURL
- .appendingPathComponent("token-other", isDirectory: true)
- .appendingPathComponent("cmux NIGHTLY 9999", isDirectory: true)
- .appendingPathComponent("other.dmg")
-
- try createFile(at: oldArchiveURL)
- try createFile(at: newArchiveURL)
- try createFile(at: otherArchiveURL)
-
- try setModificationDate(Date(timeIntervalSince1970: 100), for: oldArchiveURL)
- try setModificationDate(Date(timeIntervalSince1970: 200), for: newArchiveURL)
- try setModificationDate(Date(timeIntervalSince1970: 300), for: otherArchiveURL)
-
- let locatedArchiveURL = UpdateQuarantineRepair.locateDownloadedArchive(
- bundleIdentifier: "com.cmuxterm.app.nightly",
- hostName: "cmux NIGHTLY",
- versionString: "1234",
- cachesDirectory: cachesDirectory
- )
-
- XCTAssertEqual(locatedArchiveURL, newArchiveURL)
- }
-
- func testLocateExtractedApplicationUsesNewestMatchingBundleName() throws {
- let cachesDirectory = try makeTemporaryDirectory(named: "SparkleInstallation")
- let rootURL = UpdateQuarantineRepair.installationRootURL(
- bundleIdentifier: "com.cmuxterm.app.nightly",
- cachesDirectory: cachesDirectory
- )
-
- let oldAppURL = rootURL
- .appendingPathComponent("install-old", isDirectory: true)
- .appendingPathComponent("extract-old", isDirectory: true)
- .appendingPathComponent("cmux NIGHTLY.app", isDirectory: true)
- let newAppURL = rootURL
- .appendingPathComponent("install-new", isDirectory: true)
- .appendingPathComponent("extract-new", isDirectory: true)
- .appendingPathComponent("cmux NIGHTLY.app", isDirectory: true)
- let otherAppURL = rootURL
- .appendingPathComponent("install-other", isDirectory: true)
- .appendingPathComponent("extract-other", isDirectory: true)
- .appendingPathComponent("Different.app", isDirectory: true)
-
- try FileManager.default.createDirectory(at: oldAppURL, withIntermediateDirectories: true)
- try FileManager.default.createDirectory(at: newAppURL, withIntermediateDirectories: true)
- try FileManager.default.createDirectory(at: otherAppURL, withIntermediateDirectories: true)
-
- try setModificationDate(Date(timeIntervalSince1970: 100), for: oldAppURL)
- try setModificationDate(Date(timeIntervalSince1970: 200), for: newAppURL)
- try setModificationDate(Date(timeIntervalSince1970: 300), for: otherAppURL)
-
- let locatedAppURL = UpdateQuarantineRepair.locateExtractedApplication(
- bundleIdentifier: "com.cmuxterm.app.nightly",
- bundleName: "cmux NIGHTLY.app",
- cachesDirectory: cachesDirectory
- )
-
- XCTAssertEqual(locatedAppURL, newAppURL)
- }
-
- private func makeTemporaryDirectory(named name: String) throws -> URL {
- let directoryURL = FileManager.default.temporaryDirectory
- .appendingPathComponent("UpdateQuarantineRepairTests", isDirectory: true)
- .appendingPathComponent(UUID().uuidString, isDirectory: true)
- .appendingPathComponent(name, isDirectory: true)
- try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true)
- return directoryURL
- }
-
- private func makeTemporaryFile(named name: String) throws -> URL {
- let directoryURL = try makeTemporaryDirectory(named: "Files")
- let fileURL = directoryURL.appendingPathComponent(name)
- try createFile(at: fileURL)
- return fileURL
- }
-
- private func createFile(at url: URL) throws {
- try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
- XCTAssertTrue(FileManager.default.createFile(atPath: url.path, contents: Data()))
- }
-
- private func setModificationDate(_ modificationDate: Date, for url: URL) throws {
- try FileManager.default.setAttributes([.modificationDate: modificationDate], ofItemAtPath: url.path)
- }
-
- private func writeRawQuarantine(_ value: String, to url: URL) throws {
- let bytes = Array(value.utf8)
- let status = url.path.withCString { pathPointer in
- "com.apple.quarantine".withCString { attributePointer in
- bytes.withUnsafeBytes { bufferPointer in
- setxattr(pathPointer, attributePointer, bufferPointer.baseAddress, bytes.count, 0, 0)
- }
- }
- }
- XCTAssertEqual(status, 0)
- }
-}
From 0a99bb504ccc5999fbfaea646529386d88e4b2f3 Mon Sep 17 00:00:00 2001
From: Austin Wang
Date: Wed, 18 Mar 2026 02:32:58 -0700
Subject: [PATCH 13/24] fix: remove restricted web-browser entitlement (#1727)
---
cmux.entitlements | 2 --
1 file changed, 2 deletions(-)
diff --git a/cmux.entitlements b/cmux.entitlements
index d9bae6d6..09e191a5 100644
--- a/cmux.entitlements
+++ b/cmux.entitlements
@@ -14,7 +14,5 @@
com.apple.security.automation.apple-events
- com.apple.developer.web-browser
-
From de1aa7a6ae9d4f91871bf6dabb19ed49941df781 Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Wed, 18 Mar 2026 02:59:16 -0700
Subject: [PATCH 14/24] Include hardware details in feedback submissions
(#1726)
Add chip (e.g. Apple M1 Pro), RAM, hardware model, architecture
(arm64/x86_64), and display info to feedback metadata. All fields are
non-sensitive system properties collected via sysctlbyname, ProcessInfo,
and NSScreen. Server-side route accepts and renders the new fields in
both plain text and HTML email bodies.
Co-authored-by: Lawrence Chen
---
Sources/ContentView.swift | 53 ++++++++++++++++++++++++++++++++++-
web/app/api/feedback/route.ts | 46 ++++++++++++++++++++++++++++--
2 files changed, 96 insertions(+), 3 deletions(-)
diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift
index 181d1d0e..508b495f 100644
--- a/Sources/ContentView.swift
+++ b/Sources/ContentView.swift
@@ -8400,6 +8400,11 @@ private struct FeedbackComposerAppMetadata {
let bundleIdentifier: String
let osVersion: String
let localeIdentifier: String
+ let hardwareModel: String
+ let chip: String
+ let memoryGB: String
+ let architecture: String
+ let displayInfo: String
static var current: FeedbackComposerAppMetadata {
let infoDictionary = Bundle.main.infoDictionary ?? [:]
@@ -8414,9 +8419,50 @@ private struct FeedbackComposerAppMetadata {
appCommit: commit ?? "",
bundleIdentifier: Bundle.main.bundleIdentifier ?? "",
osVersion: ProcessInfo.processInfo.operatingSystemVersionString,
- localeIdentifier: Locale.preferredLanguages.first ?? Locale.current.identifier
+ localeIdentifier: Locale.preferredLanguages.first ?? Locale.current.identifier,
+ hardwareModel: sysctlString("hw.model") ?? "",
+ chip: sysctlString("machdep.cpu.brand_string") ?? "",
+ memoryGB: formatMemoryGB(),
+ architecture: currentArchitecture(),
+ displayInfo: currentDisplayInfo()
)
}
+
+ private static func sysctlString(_ name: String) -> String? {
+ var size = 0
+ guard sysctlbyname(name, nil, &size, nil, 0) == 0, size > 0 else { return nil }
+ var buffer = [CChar](repeating: 0, count: size)
+ guard sysctlbyname(name, &buffer, &size, nil, 0) == 0 else { return nil }
+ return String(cString: buffer).trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ private static func formatMemoryGB() -> String {
+ let bytes = ProcessInfo.processInfo.physicalMemory
+ let gb = Double(bytes) / (1024 * 1024 * 1024)
+ return "\(Int(gb)) GB"
+ }
+
+ private static func currentArchitecture() -> String {
+ #if arch(arm64)
+ return "arm64"
+ #elseif arch(x86_64)
+ return "x86_64"
+ #else
+ return "unknown"
+ #endif
+ }
+
+ private static func currentDisplayInfo() -> String {
+ let screens = NSScreen.screens
+ let descriptions = screens.map { screen -> String in
+ let frame = screen.frame
+ let scale = screen.backingScaleFactor
+ return "\(Int(frame.width))x\(Int(frame.height)) @\(Int(scale))x"
+ }
+ let count = screens.count
+ let prefix = "\(count) display\(count == 1 ? "" : "s")"
+ return "\(prefix), \(descriptions.joined(separator: "; "))"
+ }
}
private enum FeedbackComposerSubmissionError: Error {
@@ -8470,6 +8516,11 @@ private enum FeedbackComposerClient {
appendField("bundleIdentifier", value: metadata.bundleIdentifier, to: &body, boundary: boundary)
appendField("osVersion", value: metadata.osVersion, to: &body, boundary: boundary)
appendField("locale", value: metadata.localeIdentifier, to: &body, boundary: boundary)
+ appendField("hardwareModel", value: metadata.hardwareModel, to: &body, boundary: boundary)
+ appendField("chip", value: metadata.chip, to: &body, boundary: boundary)
+ appendField("memoryGB", value: metadata.memoryGB, to: &body, boundary: boundary)
+ appendField("architecture", value: metadata.architecture, to: &body, boundary: boundary)
+ appendField("displayInfo", value: metadata.displayInfo, to: &body, boundary: boundary)
for attachment in preparedAttachments {
appendFile(
diff --git a/web/app/api/feedback/route.ts b/web/app/api/feedback/route.ts
index 33256634..96560d1c 100644
--- a/web/app/api/feedback/route.ts
+++ b/web/app/api/feedback/route.ts
@@ -32,6 +32,11 @@ const feedbackSchema = z.object({
bundleIdentifier: z.string().trim().max(200).optional().default(""),
osVersion: z.string().trim().max(200).optional().default(""),
locale: z.string().trim().max(120).optional().default(""),
+ hardwareModel: z.string().trim().max(120).optional().default(""),
+ chip: z.string().trim().max(200).optional().default(""),
+ memoryGB: z.string().trim().max(20).optional().default(""),
+ architecture: z.string().trim().max(20).optional().default(""),
+ displayInfo: z.string().trim().max(200).optional().default(""),
});
type PreparedAttachment = {
@@ -83,6 +88,11 @@ export async function POST(request: Request) {
bundleIdentifier: getString(formData, "bundleIdentifier"),
osVersion: getString(formData, "osVersion"),
locale: getString(formData, "locale"),
+ hardwareModel: getString(formData, "hardwareModel"),
+ chip: getString(formData, "chip"),
+ memoryGB: getString(formData, "memoryGB"),
+ architecture: getString(formData, "architecture"),
+ displayInfo: getString(formData, "displayInfo"),
});
if (!parsed.success) {
@@ -96,8 +106,10 @@ export async function POST(request: Request) {
return attachmentsResult.errorResponse;
}
- const { appBuild, appCommit, appVersion, bundleIdentifier, email, locale, message, osVersion } =
- parsed.data;
+ const {
+ appBuild, appCommit, appVersion, architecture, bundleIdentifier, chip,
+ displayInfo, email, hardwareModel, locale, memoryGB, message, osVersion,
+ } = parsed.data;
const subject = buildSubject(email, message, appVersion);
const attachments = attachmentsResult.attachments;
const resend = new Resend(feedbackConfig.resendApiKey);
@@ -116,6 +128,11 @@ export async function POST(request: Request) {
bundleIdentifier,
osVersion,
locale,
+ hardwareModel,
+ chip,
+ memoryGB,
+ architecture,
+ displayInfo,
attachments,
}),
html: buildHtmlBody({
@@ -127,6 +144,11 @@ export async function POST(request: Request) {
bundleIdentifier,
osVersion,
locale,
+ hardwareModel,
+ chip,
+ memoryGB,
+ architecture,
+ displayInfo,
attachments,
}),
attachments: attachments.map((attachment) => ({
@@ -241,6 +263,11 @@ function buildTextBody(input: {
bundleIdentifier: string;
osVersion: string;
locale: string;
+ hardwareModel: string;
+ chip: string;
+ memoryGB: string;
+ architecture: string;
+ displayInfo: string;
attachments: PreparedAttachment[];
}) {
const attachmentLines =
@@ -262,6 +289,11 @@ function buildTextBody(input: {
`Bundle identifier: ${input.bundleIdentifier || "unknown"}`,
`macOS: ${input.osVersion || "unknown"}`,
`Locale: ${input.locale || "unknown"}`,
+ `Hardware model: ${input.hardwareModel || "unknown"}`,
+ `Chip: ${input.chip || "unknown"}`,
+ `Memory: ${input.memoryGB || "unknown"}`,
+ `Architecture: ${input.architecture || "unknown"}`,
+ `Displays: ${input.displayInfo || "unknown"}`,
attachmentLines,
"",
"Message:",
@@ -278,6 +310,11 @@ function buildHtmlBody(input: {
bundleIdentifier: string;
osVersion: string;
locale: string;
+ hardwareModel: string;
+ chip: string;
+ memoryGB: string;
+ architecture: string;
+ displayInfo: string;
attachments: PreparedAttachment[];
}) {
const attachmentMarkup =
@@ -304,6 +341,11 @@ function buildHtmlBody(input: {
)}
macOS: ${escapeHtml(input.osVersion || "unknown")}
Locale: ${escapeHtml(input.locale || "unknown")}
+ Hardware model: ${escapeHtml(input.hardwareModel || "unknown")}
+ Chip: ${escapeHtml(input.chip || "unknown")}
+ Memory: ${escapeHtml(input.memoryGB || "unknown")}
+ Architecture: ${escapeHtml(input.architecture || "unknown")}
+ Displays: ${escapeHtml(input.displayInfo || "unknown")}
${attachmentMarkup}
Message
${escapeHtml(
From cb4197ecee4fda88483f2652a5952f89d262d70e Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Wed, 18 Mar 2026 02:59:55 -0700
Subject: [PATCH 15/24] Make founders email selectable in feedback success view
(#1733)
Add .textSelection(.enabled) to the success body text so users can
select and copy the founders@manaflow.com email address.
Co-authored-by: Lawrence Chen
---
Sources/ContentView.swift | 1 +
1 file changed, 1 insertion(+)
diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift
index 508b495f..05d80107 100644
--- a/Sources/ContentView.swift
+++ b/Sources/ContentView.swift
@@ -9393,6 +9393,7 @@ private struct SidebarFeedbackComposerSheet: View {
)
.font(.system(size: 12))
.foregroundStyle(.secondary)
+ .textSelection(.enabled)
HStack {
Spacer()
From 8340c014283e44aead5ba7faa74e920b327af39e Mon Sep 17 00:00:00 2001
From: Lawrence Chen
Date: Wed, 18 Mar 2026 03:13:28 -0700
Subject: [PATCH 16/24] Add command palette ordering regression test
---
.../CommandPaletteSearchEngineTests.swift | 32 +++++++++++++++++++
1 file changed, 32 insertions(+)
diff --git a/cmuxTests/CommandPaletteSearchEngineTests.swift b/cmuxTests/CommandPaletteSearchEngineTests.swift
index 51cd4182..885fe47e 100644
--- a/cmuxTests/CommandPaletteSearchEngineTests.swift
+++ b/cmuxTests/CommandPaletteSearchEngineTests.swift
@@ -116,6 +116,29 @@ final class CommandPaletteSearchEngineTests: XCTestCase {
]
}
+ private func makeUpdateCommandEntries() -> [FixtureEntry] {
+ [
+ FixtureEntry(
+ id: "command.checkForUpdates",
+ rank: 0,
+ title: "Check for Updates",
+ searchableTexts: ["Check for Updates", "Global", "update", "upgrade", "release"]
+ ),
+ FixtureEntry(
+ id: "command.attemptUpdate",
+ rank: 1,
+ title: "Attempt Update",
+ searchableTexts: ["Attempt Update", "Global", "attempt", "check", "update", "upgrade", "release"]
+ ),
+ FixtureEntry(
+ id: "command.applyUpdateIfAvailable",
+ rank: 2,
+ title: "Apply Update (If Available)",
+ searchableTexts: ["Apply Update (If Available)", "Global", "apply", "install", "update", "available"]
+ ),
+ ]
+ }
+
private func optimizedResults(
entries: [FixtureEntry],
query: String
@@ -322,6 +345,15 @@ final class CommandPaletteSearchEngineTests: XCTestCase {
"command.finder"
)
}
+ func testSearchPrefersTitleMatchOverKeywordOnlyMatchForCheckQuery() {
+ let results = optimizedResults(entries: makeUpdateCommandEntries(), query: "check")
+
+ XCTAssertEqual(
+ results.prefix(2).map(\.id),
+ ["command.checkForUpdates", "command.attemptUpdate"]
+ )
+ }
+
func testResolvedSelectionIndexPrefersAnchoredCommand() {
let resultIDs = ["command.0", "command.1", "command.2"]
From 2c81192299917b42d984f859b17ecb7094e2b553 Mon Sep 17 00:00:00 2001
From: Lawrence Chen
Date: Wed, 18 Mar 2026 03:13:37 -0700
Subject: [PATCH 17/24] Prefer command palette title matches over keywords
---
Sources/ContentView.swift | 28 ++++++++-
.../CommandPaletteSearchEngineTests.swift | 59 ++++++++++++-------
cmuxUITests/SidebarHelpMenuUITests.swift | 37 ++++++++++++
3 files changed, 102 insertions(+), 22 deletions(-)
diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift
index 05d80107..34eb2178 100644
--- a/Sources/ContentView.swift
+++ b/Sources/ContentView.swift
@@ -7926,12 +7926,14 @@ struct CommandPaletteSearchCorpusEntry: Sendable where Payload: Sendabl
let payload: Payload
let rank: Int
let title: String
+ let normalizedTitle: String
let normalizedSearchableTexts: [String]
init(payload: Payload, rank: Int, title: String, searchableTexts: [String]) {
self.payload = payload
self.rank = rank
self.title = title
+ self.normalizedTitle = CommandPaletteFuzzyMatcher.normalizeForSearch(title)
self.normalizedSearchableTexts = searchableTexts
.map(CommandPaletteFuzzyMatcher.normalizeForSearch)
.filter { !$0.isEmpty }
@@ -7947,6 +7949,8 @@ struct CommandPaletteSearchCorpusResult: Sendable where Payload: Sendab
}
enum CommandPaletteSearchEngine {
+ private static let titleMatchBonus = 2000
+
static func search(
entries: [CommandPaletteSearchCorpusEntry],
query: String,
@@ -8006,9 +8010,9 @@ enum CommandPaletteSearchEngine {
} else {
for (index, entry) in entries.enumerated() {
if shouldCancelSearch(at: index) { return [] }
- guard let fuzzyScore = CommandPaletteFuzzyMatcher.score(
+ guard let fuzzyScore = weightedScore(
preparedQuery: preparedQuery,
- normalizedCandidates: entry.normalizedSearchableTexts
+ entry: entry
) else {
continue
}
@@ -8035,6 +8039,26 @@ enum CommandPaletteSearchEngine {
return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending
}
}
+
+ private static func weightedScore(
+ preparedQuery: CommandPaletteFuzzyMatcher.PreparedQuery,
+ entry: CommandPaletteSearchCorpusEntry
+ ) -> Int? {
+ guard let fuzzyScore = CommandPaletteFuzzyMatcher.score(
+ preparedQuery: preparedQuery,
+ normalizedCandidates: entry.normalizedSearchableTexts
+ ) else {
+ return nil
+ }
+ guard !entry.normalizedTitle.isEmpty,
+ let titleScore = CommandPaletteFuzzyMatcher.score(
+ preparedQuery: preparedQuery,
+ normalizedCandidates: [entry.normalizedTitle]
+ ) else {
+ return fuzzyScore
+ }
+ return max(fuzzyScore, titleScore + titleMatchBonus)
+ }
}
private struct SidebarResizerAccessibilityModifier: ViewModifier {
diff --git a/cmuxTests/CommandPaletteSearchEngineTests.swift b/cmuxTests/CommandPaletteSearchEngineTests.swift
index 885fe47e..b0c8d2e0 100644
--- a/cmuxTests/CommandPaletteSearchEngineTests.swift
+++ b/cmuxTests/CommandPaletteSearchEngineTests.swift
@@ -164,7 +164,7 @@ final class CommandPaletteSearchEngineTests: XCTestCase {
}
}
- private func legacyResults(
+ private func referenceResults(
entries: [FixtureEntry],
query: String
) -> [FixtureResult] {
@@ -174,9 +174,9 @@ final class CommandPaletteSearchEngineTests: XCTestCase {
FixtureResult(id: entry.id, rank: entry.rank, title: entry.title, score: 0, titleMatchIndices: [])
}
: entries.compactMap { entry in
- guard let fuzzyScore = CommandPaletteFuzzyMatcher.score(
+ guard let fuzzyScore = weightedReferenceScore(
query: query,
- candidates: entry.searchableTexts
+ entry: entry
) else {
return nil
}
@@ -186,7 +186,7 @@ final class CommandPaletteSearchEngineTests: XCTestCase {
title: entry.title,
score: fuzzyScore,
titleMatchIndices: CommandPaletteFuzzyMatcher.matchCharacterIndices(
- query: query,
+ query: query,
candidate: entry.title
)
)
@@ -199,6 +199,25 @@ final class CommandPaletteSearchEngineTests: XCTestCase {
}
}
+ private func weightedReferenceScore(
+ query: String,
+ entry: FixtureEntry
+ ) -> Int? {
+ guard let fuzzyScore = CommandPaletteFuzzyMatcher.score(
+ query: query,
+ candidates: entry.searchableTexts
+ ) else {
+ return nil
+ }
+ guard let titleScore = CommandPaletteFuzzyMatcher.score(
+ query: query,
+ candidate: entry.title
+ ) else {
+ return fuzzyScore
+ }
+ return max(fuzzyScore, titleScore + 2000)
+ }
+
private func benchmarkElapsedMs(operation: () -> Void) -> Double {
let start = DispatchTime.now().uptimeNanoseconds
operation()
@@ -210,7 +229,7 @@ final class CommandPaletteSearchEngineTests: XCTestCase {
Array(repeating: baseQueries, count: repetitions).flatMap { $0 }
}
- func testOptimizedSearchMatchesLegacyPipeline() {
+ func testOptimizedSearchMatchesReferencePipeline() {
let commandEntries = makeCommandEntries(count: 96)
let switcherEntries = makeSwitcherEntries(count: 64)
let queries = [
@@ -228,12 +247,12 @@ final class CommandPaletteSearchEngineTests: XCTestCase {
for query in queries {
XCTAssertEqual(
optimizedResults(entries: commandEntries, query: query),
- legacyResults(entries: commandEntries, query: query),
+ referenceResults(entries: commandEntries, query: query),
"Command corpus mismatch for query \(query)"
)
XCTAssertEqual(
optimizedResults(entries: switcherEntries, query: query),
- legacyResults(entries: switcherEntries, query: query),
+ referenceResults(entries: switcherEntries, query: query),
"Switcher corpus mismatch for query \(query)"
)
}
@@ -345,6 +364,7 @@ final class CommandPaletteSearchEngineTests: XCTestCase {
"command.finder"
)
}
+
func testSearchPrefersTitleMatchOverKeywordOnlyMatchForCheckQuery() {
let results = optimizedResults(entries: makeUpdateCommandEntries(), query: "check")
@@ -354,7 +374,6 @@ final class CommandPaletteSearchEngineTests: XCTestCase {
)
}
-
func testResolvedSelectionIndexPrefersAnchoredCommand() {
let resultIDs = ["command.0", "command.1", "command.2"]
@@ -811,13 +830,13 @@ final class CommandPaletteSearchEngineTests: XCTestCase {
)
for query in queries.prefix(8) {
- _ = legacyResults(entries: entries, query: query)
+ _ = referenceResults(entries: entries, query: query)
_ = CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 }
}
- let legacyMs = benchmarkElapsedMs {
+ let referenceMs = benchmarkElapsedMs {
for query in queries {
- _ = legacyResults(entries: entries, query: query)
+ _ = referenceResults(entries: entries, query: query)
}
}
let optimizedMs = benchmarkElapsedMs {
@@ -826,11 +845,11 @@ final class CommandPaletteSearchEngineTests: XCTestCase {
}
}
- print(String(format: "BENCH cmd+shift+p legacy=%.2fms optimized=%.2fms", legacyMs, optimizedMs))
+ print(String(format: "BENCH cmd+shift+p reference=%.2fms optimized=%.2fms", referenceMs, optimizedMs))
XCTAssertLessThan(
optimizedMs,
- legacyMs * 1.25,
- "Optimized command search regressed significantly: legacy=\(legacyMs) optimized=\(optimizedMs)"
+ referenceMs * 1.25,
+ "Optimized command search regressed significantly: reference=\(referenceMs) optimized=\(optimizedMs)"
)
}
@@ -850,13 +869,13 @@ final class CommandPaletteSearchEngineTests: XCTestCase {
)
for query in queries.prefix(8) {
- _ = legacyResults(entries: entries, query: query)
+ _ = referenceResults(entries: entries, query: query)
_ = CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 }
}
- let legacyMs = benchmarkElapsedMs {
+ let referenceMs = benchmarkElapsedMs {
for query in queries {
- _ = legacyResults(entries: entries, query: query)
+ _ = referenceResults(entries: entries, query: query)
}
}
let optimizedMs = benchmarkElapsedMs {
@@ -865,11 +884,11 @@ final class CommandPaletteSearchEngineTests: XCTestCase {
}
}
- print(String(format: "BENCH cmd+p legacy=%.2fms optimized=%.2fms", legacyMs, optimizedMs))
+ print(String(format: "BENCH cmd+p reference=%.2fms optimized=%.2fms", referenceMs, optimizedMs))
XCTAssertLessThan(
optimizedMs,
- legacyMs * 1.25,
- "Optimized switcher search regressed significantly: legacy=\(legacyMs) optimized=\(optimizedMs)"
+ referenceMs * 1.25,
+ "Optimized switcher search regressed significantly: reference=\(referenceMs) optimized=\(optimizedMs)"
)
}
}
diff --git a/cmuxUITests/SidebarHelpMenuUITests.swift b/cmuxUITests/SidebarHelpMenuUITests.swift
index b16fdc33..f058d083 100644
--- a/cmuxUITests/SidebarHelpMenuUITests.swift
+++ b/cmuxUITests/SidebarHelpMenuUITests.swift
@@ -410,6 +410,43 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase {
)
}
+ func testCmdShiftPCheckQueryPrefersCheckForUpdatesBeforeAttemptUpdate() throws {
+ let app = XCUIApplication()
+ configureSocketControlledLaunch(app)
+ launchAndActivate(app)
+
+ XCTAssertTrue(
+ sidebarHelpPollUntil(timeout: 8.0) {
+ app.windows.count >= 1
+ },
+ "Expected the main window to be visible"
+ )
+ XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket at \(socketPath)")
+
+ let mainWindowId = try XCTUnwrap(
+ socketCommand("current_window")?.trimmingCharacters(in: .whitespacesAndNewlines)
+ )
+
+ openCommandPaletteCommands(app: app)
+ try debugTypeText("check")
+
+ let snapshot = try XCTUnwrap(
+ waitForCommandPaletteSnapshot(windowId: mainWindowId, mode: "commands", query: "check", timeout: 5.0) { snapshot in
+ let rows = self.commandPaletteResultRows(from: snapshot)
+ guard rows.count >= 2 else { return false }
+ let firstCommandId = rows[0]["command_id"] as? String
+ let secondCommandId = rows[1]["command_id"] as? String
+ return firstCommandId == "palette.checkForUpdates"
+ && secondCommandId == "palette.attemptUpdate"
+ },
+ "Expected the check query to rank Check for Updates before Attempt Update"
+ )
+
+ let rows = commandPaletteResultRows(from: snapshot)
+ XCTAssertEqual(rows.first?["command_id"] as? String, "palette.checkForUpdates")
+ XCTAssertEqual(rows.first?["title"] as? String, "Check for Updates")
+ }
+
func testCmdPSearchCanIncludeSurfacesFromOtherWorkspacesWhenEnabled() throws {
let app = XCUIApplication()
configureSocketControlledLaunch(app, showSettingsWindow: true)
From 377d62e8fe4cf4ad8458957a4edc45f7a105f266 Mon Sep 17 00:00:00 2001
From: Lawrence Chen
Date: Wed, 18 Mar 2026 03:19:29 -0700
Subject: [PATCH 18/24] Fix empty env prefix in e2e workflow
---
.github/workflows/test-e2e.yml | 21 +++++++++++++++------
1 file changed, 15 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml
index 6f651725..569f27c6 100644
--- a/.github/workflows/test-e2e.yml
+++ b/.github/workflows/test-e2e.yml
@@ -289,13 +289,22 @@ jobs:
fi
fi
+ XCODEBUILD_CMD=(
+ xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug
+ -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR"
+ -disableAutomaticPackageResolution
+ -destination "platform=macOS"
+ -maximum-test-execution-time-allowance "$TEST_TIMEOUT"
+ $ONLY_TESTING
+ test
+ )
+
set +e
- OUTPUT=$(env "${DISPLAY_ENV_PREFIX[@]}" xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \
- -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
- -disableAutomaticPackageResolution \
- -destination "platform=macOS" \
- -maximum-test-execution-time-allowance "$TEST_TIMEOUT" \
- $ONLY_TESTING test 2>&1)
+ if [ "${#DISPLAY_ENV_PREFIX[@]}" -gt 0 ]; then
+ OUTPUT=$(env "${DISPLAY_ENV_PREFIX[@]}" "${XCODEBUILD_CMD[@]}" 2>&1)
+ else
+ OUTPUT=$("${XCODEBUILD_CMD[@]}" 2>&1)
+ fi
EXIT_CODE=$?
set -e
From d33e1d1cf93b52add8659ec1ddea106f4aa032d0 Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Wed, 18 Mar 2026 03:22:58 -0700
Subject: [PATCH 19/24] SEO hardening: canonicals, sitemap x-default, legal
dates (#1741)
- Add canonical tags to community, nightly, wall-of-love, and all
legal pages (prevents duplicate content with trailing slashes)
- Add x-default hreflang to sitemap for all entries
- Add legal pages (privacy-policy, terms-of-service, eula) to sitemap
- Stabilize sitemap lastModified to fixed dates instead of new Date()
(avoids noisy lastmod changes on every deploy)
- Update legal page dates to March 18, 2026 (domain migration is a
material change to the "Site" definition)
- Update copyright year to 2026
Co-authored-by: Lawrence Chen
---
web/app/[locale]/(legal)/eula/page.tsx | 3 +-
.../[locale]/(legal)/privacy-policy/page.tsx | 3 +-
.../(legal)/terms-of-service/page.tsx | 5 ++--
web/app/[locale]/community/page.tsx | 1 +
web/app/[locale]/nightly/page.tsx | 1 +
web/app/[locale]/wall-of-love/page.tsx | 1 +
web/app/sitemap.ts | 30 +++++++++++--------
7 files changed, 27 insertions(+), 17 deletions(-)
diff --git a/web/app/[locale]/(legal)/eula/page.tsx b/web/app/[locale]/(legal)/eula/page.tsx
index 85676b2d..ddc9ae0a 100644
--- a/web/app/[locale]/(legal)/eula/page.tsx
+++ b/web/app/[locale]/(legal)/eula/page.tsx
@@ -3,13 +3,14 @@ import type { Metadata } from "next";
export const metadata: Metadata = {
title: "EULA — cmux",
description: "End-User License Agreement for cmux",
+ alternates: { canonical: "./" },
};
export default function EulaPage() {
return (
<>
EULA
- Last updated: December 2, 2025
+ Last updated: March 18, 2026
Please read this End-User License Agreement carefully before
diff --git a/web/app/[locale]/(legal)/privacy-policy/page.tsx b/web/app/[locale]/(legal)/privacy-policy/page.tsx
index 98792851..d6b767db 100644
--- a/web/app/[locale]/(legal)/privacy-policy/page.tsx
+++ b/web/app/[locale]/(legal)/privacy-policy/page.tsx
@@ -4,13 +4,14 @@ import { Link } from "../../../../i18n/navigation";
export const metadata: Metadata = {
title: "Privacy Policy — cmux",
description: "Privacy policy for cmux",
+ alternates: { canonical: "./" },
};
export default function PrivacyPolicyPage() {
return (
<>
Privacy Policy
- Last updated: December 2, 2025
+ Last updated: March 18, 2026
Manaflow (the “Company”) is committed to maintaining robust
diff --git a/web/app/[locale]/(legal)/terms-of-service/page.tsx b/web/app/[locale]/(legal)/terms-of-service/page.tsx
index 76b3f48b..c02b8ab8 100644
--- a/web/app/[locale]/(legal)/terms-of-service/page.tsx
+++ b/web/app/[locale]/(legal)/terms-of-service/page.tsx
@@ -3,13 +3,14 @@ import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Terms of Service — cmux",
description: "Terms of service for cmux",
+ alternates: { canonical: "./" },
};
export default function TermsOfServicePage() {
return (
<>
Terms of Service
- Last revised on: December 2, 2025
+ Last revised on: March 18, 2026
The website located at{" "}
@@ -180,7 +181,7 @@ export default function TermsOfServicePage() {
- Copyright © 2025 Manaflow. All rights reserved.
+ Copyright © 2026 Manaflow. All rights reserved.
>
);
diff --git a/web/app/[locale]/community/page.tsx b/web/app/[locale]/community/page.tsx
index cce06e02..3742df3b 100644
--- a/web/app/[locale]/community/page.tsx
+++ b/web/app/[locale]/community/page.tsx
@@ -8,6 +8,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
return {
title: t("metaTitle"),
description: t("metaDescription"),
+ alternates: { canonical: "./" },
};
}
diff --git a/web/app/[locale]/nightly/page.tsx b/web/app/[locale]/nightly/page.tsx
index 35af11df..d3fb1a31 100644
--- a/web/app/[locale]/nightly/page.tsx
+++ b/web/app/[locale]/nightly/page.tsx
@@ -12,6 +12,7 @@ export async function generateMetadata({
return {
title: t("metaTitle"),
description: t("metaDescription"),
+ alternates: { canonical: "./" },
};
}
diff --git a/web/app/[locale]/wall-of-love/page.tsx b/web/app/[locale]/wall-of-love/page.tsx
index 5f26b0d8..c1ec7d41 100644
--- a/web/app/[locale]/wall-of-love/page.tsx
+++ b/web/app/[locale]/wall-of-love/page.tsx
@@ -9,6 +9,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
return {
title: t("metaTitle"),
description: t("metaDescription"),
+ alternates: { canonical: "./" },
};
}
diff --git a/web/app/sitemap.ts b/web/app/sitemap.ts
index 6705f4dc..ecc6e0bb 100644
--- a/web/app/sitemap.ts
+++ b/web/app/sitemap.ts
@@ -5,23 +5,26 @@ export default function sitemap(): MetadataRoute.Sitemap {
const base = "https://cmux.com";
const paths = [
- { path: "", lastModified: new Date(), changeFrequency: "weekly" as const, priority: 1 },
- { path: "/blog", lastModified: new Date(), changeFrequency: "weekly" as const, priority: 0.8 },
+ { path: "", lastModified: "2026-03-18", changeFrequency: "weekly" as const, priority: 1 },
+ { path: "/blog", lastModified: "2026-03-18", changeFrequency: "weekly" as const, priority: 0.8 },
{ path: "/blog/show-hn-launch", lastModified: "2026-02-21", changeFrequency: "monthly" as const, priority: 0.7 },
{ path: "/blog/introducing-cmux", lastModified: "2026-02-12", changeFrequency: "monthly" as const, priority: 0.7 },
{ path: "/blog/zen-of-cmux", lastModified: "2026-02-27", changeFrequency: "monthly" as const, priority: 0.7 },
{ path: "/blog/cmd-shift-u", lastModified: "2026-03-04", changeFrequency: "monthly" as const, priority: 0.7 },
- { path: "/docs/getting-started", lastModified: new Date(), changeFrequency: "monthly" as const, priority: 0.9 },
- { path: "/docs/concepts", lastModified: new Date(), changeFrequency: "monthly" as const, priority: 0.8 },
- { path: "/docs/configuration", lastModified: new Date(), changeFrequency: "monthly" as const, priority: 0.8 },
- { path: "/docs/keyboard-shortcuts", lastModified: new Date(), changeFrequency: "monthly" as const, priority: 0.7 },
- { path: "/docs/api", lastModified: new Date(), changeFrequency: "monthly" as const, priority: 0.8 },
- { path: "/docs/notifications", lastModified: new Date(), changeFrequency: "monthly" as const, priority: 0.8 },
- { path: "/docs/changelog", lastModified: new Date(), changeFrequency: "weekly" as const, priority: 0.5 },
- { path: "/docs/browser-automation", lastModified: new Date(), changeFrequency: "monthly" as const, priority: 0.8 },
- { path: "/community", lastModified: new Date(), changeFrequency: "monthly" as const, priority: 0.5 },
- { path: "/wall-of-love", lastModified: new Date(), changeFrequency: "monthly" as const, priority: 0.5 },
- { path: "/nightly", lastModified: new Date(), changeFrequency: "weekly" as const, priority: 0.6 },
+ { path: "/docs/getting-started", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.9 },
+ { path: "/docs/concepts", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.8 },
+ { path: "/docs/configuration", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.8 },
+ { path: "/docs/keyboard-shortcuts", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.7 },
+ { path: "/docs/api", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.8 },
+ { path: "/docs/notifications", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.8 },
+ { path: "/docs/changelog", lastModified: "2026-03-18", changeFrequency: "weekly" as const, priority: 0.5 },
+ { path: "/docs/browser-automation", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.8 },
+ { path: "/community", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.5 },
+ { path: "/wall-of-love", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.5 },
+ { path: "/nightly", lastModified: "2026-03-18", changeFrequency: "weekly" as const, priority: 0.6 },
+ { path: "/privacy-policy", lastModified: "2026-03-18", changeFrequency: "yearly" as const, priority: 0.3 },
+ { path: "/terms-of-service", lastModified: "2026-03-18", changeFrequency: "yearly" as const, priority: 0.3 },
+ { path: "/eula", lastModified: "2026-03-18", changeFrequency: "yearly" as const, priority: 0.3 },
];
const entries: MetadataRoute.Sitemap = [];
@@ -32,6 +35,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
alternates[locale] =
locale === "en" ? `${base}${path}` : `${base}/${locale}${path}`;
}
+ alternates["x-default"] = `${base}${path}`;
entries.push({
url: `${base}${path}`,
From 8a74dc2d0503725f941a54b2d27b8b4a4825750e Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Wed, 18 Mar 2026 03:24:15 -0700
Subject: [PATCH 20/24] Use dynamic copyright year in Terms of Service (#1742)
SSR the current year instead of hardcoding it.
Co-authored-by: Lawrence Chen
---
web/app/[locale]/(legal)/terms-of-service/page.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/web/app/[locale]/(legal)/terms-of-service/page.tsx b/web/app/[locale]/(legal)/terms-of-service/page.tsx
index c02b8ab8..56b4b98e 100644
--- a/web/app/[locale]/(legal)/terms-of-service/page.tsx
+++ b/web/app/[locale]/(legal)/terms-of-service/page.tsx
@@ -181,7 +181,7 @@ export default function TermsOfServicePage() {
- Copyright © 2026 Manaflow. All rights reserved.
+ Copyright © {new Date().getFullYear()} Manaflow. All rights reserved.
>
);
From 8512e6d8a51fc4e1d1e03366ba3e9c53913806a9 Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Wed, 18 Mar 2026 03:24:40 -0700
Subject: [PATCH 21/24] fix: skip identical session autosave writes (#1732)
* test: cover repeated identical session saves
* fix: skip identical session snapshot writes
---------
Co-authored-by: Lawrence Chen
---
Sources/SessionPersistence.swift | 13 +++++++++---
cmuxTests/SessionPersistenceTests.swift | 27 +++++++++++++++++++++++++
2 files changed, 37 insertions(+), 3 deletions(-)
diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift
index b0303d53..188833d4 100644
--- a/Sources/SessionPersistence.swift
+++ b/Sources/SessionPersistence.swift
@@ -377,9 +377,10 @@ enum SessionPersistenceStore {
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)
+ let data = try encodedSnapshotData(snapshot)
+ if let existingData = try? Data(contentsOf: fileURL), existingData == data {
+ return true
+ }
try data.write(to: fileURL, options: .atomic)
return true
} catch {
@@ -387,6 +388,12 @@ enum SessionPersistenceStore {
}
}
+ private static func encodedSnapshotData(_ snapshot: AppSessionSnapshot) throws -> Data {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ return try encoder.encode(snapshot)
+ }
+
static func removeSnapshot(fileURL: URL? = nil) {
guard let fileURL = fileURL ?? defaultSnapshotFileURL() else { return }
try? FileManager.default.removeItem(at: fileURL)
diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift
index 7d04db1d..8c00c0c1 100644
--- a/cmuxTests/SessionPersistenceTests.swift
+++ b/cmuxTests/SessionPersistenceTests.swift
@@ -86,6 +86,28 @@ final class SessionPersistenceTests: XCTestCase {
)
}
+ func testSaveSkipsRewritingIdenticalSnapshotData() throws {
+ 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 firstFileNumber = try fileNumber(for: snapshotURL)
+
+ XCTAssertTrue(SessionPersistenceStore.save(snapshot, fileURL: snapshotURL))
+ let secondFileNumber = try fileNumber(for: snapshotURL)
+
+ XCTAssertEqual(
+ secondFileNumber,
+ firstFileNumber,
+ "Saving identical session data should not replace the snapshot file"
+ )
+ }
+
func testWorkspaceCustomColorDecodeSupportsMissingLegacyField() throws {
var snapshot = makeSnapshot(version: SessionSnapshotSchema.currentVersion)
snapshot.windows[0].tabManager.workspaces[0].customColor = nil
@@ -780,6 +802,11 @@ final class SessionPersistenceTests: XCTestCase {
windows: [window]
)
}
+
+ private func fileNumber(for fileURL: URL) throws -> Int {
+ let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path)
+ return try XCTUnwrap(attributes[.systemFileNumber] as? Int)
+ }
}
final class SocketListenerAcceptPolicyTests: XCTestCase {
From e569254ef33989ab621a78ef0274c7230cc2a80b Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Wed, 18 Mar 2026 03:25:31 -0700
Subject: [PATCH 22/24] Wire split test files into build phase, add test
timeout (#1743)
The previous PR (#1717) added 15 test files to the pbxproj PBXBuildFile and
PBXGroup sections but missed adding them to the cmuxTests Sources build phase
(F1000005), so they were never compiled in CI.
Also add executionTimeAllowance = 30s to AppDelegateShortcutRoutingTests to
prevent testCmdWClosesWindowWhenClosingLastSurfaceInLastWorkspace from hanging
indefinitely on CI (the actual root cause of the timeout).
Co-authored-by: Lawrence Chen
---
GhosttyTabs.xcodeproj/project.pbxproj | 15 +++++++++++++++
cmuxTests/AppDelegateShortcutRoutingTests.swift | 2 ++
2 files changed, 17 insertions(+)
diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj
index be770cf8..23506926 100644
--- a/GhosttyTabs.xcodeproj/project.pbxproj
+++ b/GhosttyTabs.xcodeproj/project.pbxproj
@@ -778,6 +778,21 @@
FA000000A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift in Sources */,
A5008381 /* BrowserFindJavaScriptTests.swift in Sources */,
A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */,
+ E12E88F82733EC42F32C36A3 /* BrowserConfigTests.swift in Sources */,
+ 1F14445B9627DE9D3AF4FD2E /* BrowserPanelTests.swift in Sources */,
+ 46F6AC15863EC84DCD3770A2 /* TerminalAndGhosttyTests.swift in Sources */,
+ 6B524A0BA34FD46A771335AB /* WorkspaceUnitTests.swift in Sources */,
+ 063BC42CEE257D6213A2E30C /* WindowAndDragTests.swift in Sources */,
+ 1521D55DC63D5E5FC4955E31 /* ShortcutAndCommandPaletteTests.swift in Sources */,
+ CB23911D7E131E8FBC9B82B6 /* SidebarOrderingTests.swift in Sources */,
+ 4378399A7C0245EF8186F306 /* OmnibarAndToolsTests.swift in Sources */,
+ 734F49D37E543DD01C2F4FEF /* NotificationAndMenuBarTests.swift in Sources */,
+ B6BF3DC98DB1495E57900199 /* TabManagerUnitTests.swift in Sources */,
+ DCC935C5F55C1DCB33E25521 /* WorkspacePullRequestSidebarTests.swift in Sources */,
+ 0F2C25F9170130F8DC09DD1B /* WorkspaceManualUnreadTests.swift in Sources */,
+ CA39C0304FE351A21C372429 /* SidebarWidthPolicyTests.swift in Sources */,
+ 8C4BBF2DEF6DF93F395A9EE7 /* TerminalControllerSocketSecurityTests.swift in Sources */,
+ 2BB56A710BB1FC50367E5BCF /* TabManagerSessionSnapshotTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift
index 2dbc01ab..8bc58a59 100644
--- a/cmuxTests/AppDelegateShortcutRoutingTests.swift
+++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift
@@ -15,6 +15,8 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
override func setUp() {
super.setUp()
+ // Prevent a single hanging test from consuming the entire CI timeout budget.
+ executionTimeAllowance = 30
actionsWithPersistedShortcut = Set(
KeyboardShortcutSettings.Action.allCases.filter {
UserDefaults.standard.object(forKey: $0.defaultsKey) != nil
From e4ded739828dcfcba0d4b3572b11f5fb44c39a53 Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Wed, 18 Mar 2026 03:30:09 -0700
Subject: [PATCH 23/24] Add PostHog and Resend disclosures to privacy policy
(#1744)
The privacy policy previously only mentioned Sentry, Sparkle, and
Ghostty. Added PostHog (website analytics, cookies) and Resend
(transactional email for feedback) to both the data collection
section and the third-party services list.
Co-authored-by: Lawrence Chen
---
.../[locale]/(legal)/privacy-policy/page.tsx | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
diff --git a/web/app/[locale]/(legal)/privacy-policy/page.tsx b/web/app/[locale]/(legal)/privacy-policy/page.tsx
index d6b767db..f0940728 100644
--- a/web/app/[locale]/(legal)/privacy-policy/page.tsx
+++ b/web/app/[locale]/(legal)/privacy-policy/page.tsx
@@ -58,6 +58,13 @@ export default function PrivacyPolicyPage() {
The Application checks for updates via Sparkle, which may transmit your
operating system version and application version to our update server.
+
+ The Site uses PostHog for anonymous analytics, including page views and
+ navigation patterns. PostHog stores a cookie to distinguish unique
+ visitors. No personally identifiable information is collected through
+ analytics. You can opt out by using a browser extension that blocks
+ tracking scripts.
+
2. Information you provide directly
@@ -91,6 +98,16 @@ export default function PrivacyPolicyPage() {
Ghostty / libghostty — terminal rendering
engine. Runs entirely locally on your device.
+
+ PostHog — website analytics. Collects anonymous
+ page view data, navigation patterns, and browser metadata via a
+ first-party proxy. No personally identifiable information is collected.
+
+
+ Resend — transactional email delivery. Used to
+ deliver feedback submissions from the Application. Your email address
+ is transmitted to Resend only if you voluntarily submit feedback.
+
Each of these services has its own privacy policy governing the
From d63f85271916844f70a0447eafc8d5baea990810 Mon Sep 17 00:00:00 2001
From: Lawrence Chen
Date: Wed, 18 Mar 2026 03:34:36 -0700
Subject: [PATCH 24/24] Stabilize command palette ordering UI test
---
Sources/ContentView.swift | 2 ++
cmuxUITests/SidebarHelpMenuUITests.swift | 36 +++++++++++-------------
2 files changed, 18 insertions(+), 20 deletions(-)
diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift
index 34eb2178..b9ac6437 100644
--- a/Sources/ContentView.swift
+++ b/Sources/ContentView.swift
@@ -3363,6 +3363,8 @@ struct ContentView: View {
.contentShape(Rectangle())
}
.buttonStyle(.plain)
+ .accessibilityIdentifier("CommandPaletteResultRow.\(index)")
+ .accessibilityValue(result.id)
.id(index)
.onHover { hovering in
if hovering {
diff --git a/cmuxUITests/SidebarHelpMenuUITests.swift b/cmuxUITests/SidebarHelpMenuUITests.swift
index f058d083..d52ae8f5 100644
--- a/cmuxUITests/SidebarHelpMenuUITests.swift
+++ b/cmuxUITests/SidebarHelpMenuUITests.swift
@@ -412,7 +412,8 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase {
func testCmdShiftPCheckQueryPrefersCheckForUpdatesBeforeAttemptUpdate() throws {
let app = XCUIApplication()
- configureSocketControlledLaunch(app)
+ app.launchArguments += ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"]
+ app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
launchAndActivate(app)
XCTAssertTrue(
@@ -421,30 +422,25 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase {
},
"Expected the main window to be visible"
)
- XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket at \(socketPath)")
-
- let mainWindowId = try XCTUnwrap(
- socketCommand("current_window")?.trimmingCharacters(in: .whitespacesAndNewlines)
- )
openCommandPaletteCommands(app: app)
- try debugTypeText("check")
+ let searchField = app.textFields["CommandPaletteSearchField"]
+ searchField.typeText("check")
- let snapshot = try XCTUnwrap(
- waitForCommandPaletteSnapshot(windowId: mainWindowId, mode: "commands", query: "check", timeout: 5.0) { snapshot in
- let rows = self.commandPaletteResultRows(from: snapshot)
- guard rows.count >= 2 else { return false }
- let firstCommandId = rows[0]["command_id"] as? String
- let secondCommandId = rows[1]["command_id"] as? String
- return firstCommandId == "palette.checkForUpdates"
- && secondCommandId == "palette.attemptUpdate"
+ let row0 = app.descendants(matching: .any).matching(identifier: "CommandPaletteResultRow.0").firstMatch
+ let row1 = app.descendants(matching: .any).matching(identifier: "CommandPaletteResultRow.1").firstMatch
+
+ XCTAssertTrue(
+ sidebarHelpPollUntil(timeout: 5.0) {
+ row0.exists &&
+ row1.exists &&
+ (row0.value as? String) == "palette.checkForUpdates" &&
+ (row1.value as? String) == "palette.attemptUpdate"
},
- "Expected the check query to rank Check for Updates before Attempt Update"
+ "Expected the check query to rank Check for Updates before Attempt Update. row0=\(String(describing: row0.value)) row1=\(String(describing: row1.value))"
)
-
- let rows = commandPaletteResultRows(from: snapshot)
- XCTAssertEqual(rows.first?["command_id"] as? String, "palette.checkForUpdates")
- XCTAssertEqual(rows.first?["title"] as? String, "Check for Updates")
+ XCTAssertEqual(row0.value as? String, "palette.checkForUpdates")
+ XCTAssertEqual(row1.value as? String, "palette.attemptUpdate")
}
func testCmdPSearchCanIncludeSurfacesFromOtherWorkspacesWhenEnabled() throws {