Merge branch 'main' into issue-151-ssh-remote-port-proxying

This commit is contained in:
Lawrence Chen 2026-03-11 15:56:47 -07:00
commit d67090994e
61 changed files with 11220 additions and 614 deletions

View file

@ -831,6 +831,54 @@ final class AppDelegateWindowContextRoutingTests: XCTestCase {
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 }))
}
}
@MainActor
@ -2389,14 +2437,26 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
private final class WKInspectorProbeView: NSView {}
private final class FakeInspector: NSObject {
private(set) var attachCount = 0
private(set) var showCount = 0
private(set) var closeCount = 0
private var visible = false
private var attached = false
@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
@ -2405,6 +2465,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
@objc func close() {
closeCount += 1
visible = false
attached = false
}
}
@ -2420,6 +2481,18 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
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
}
func testRestoreReopensInspectorAfterAttachWhenPreferredVisible() {
let (panel, inspector) = makePanelWithInspector()
@ -2537,6 +2610,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
panel: panel,
paneId: paneId,
shouldAttachWebView: true,
useLocalInlineHosting: false,
shouldFocusWebView: false,
isPanelFocused: true,
portalZPriority: 0,
@ -2578,6 +2652,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
panel: panel,
paneId: paneId,
shouldAttachWebView: true,
useLocalInlineHosting: false,
shouldFocusWebView: false,
isPanelFocused: true,
portalZPriority: 0,
@ -2628,6 +2703,89 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
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"
)
}
}
final class WorkspaceShortcutMapperTests: XCTestCase {
@ -4708,6 +4866,158 @@ final class TabManagerEqualizeSplitsTests: XCTestCase {
}
}
@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() {
@ -6247,6 +6557,23 @@ final class VSCodeServeWebControllerTests: XCTestCase {
}
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 {
@ -7544,6 +7871,24 @@ final class TerminalNotificationDirectInteractionTests: XCTestCase {
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 }
@ -7624,6 +7969,76 @@ final class TerminalNotificationDirectInteractionTests: XCTestCase {
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)
}
}
@ -8102,6 +8517,14 @@ final class WindowBrowserHostViewTests: XCTestCase {
}
}
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 {
@ -8191,6 +8614,60 @@ final class WindowBrowserHostViewTests: XCTestCase {
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(
@ -8624,6 +9101,65 @@ final class WindowBrowserHostViewTests: XCTestCase {
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),
@ -8691,6 +9227,14 @@ final class BrowserPanelHostContainerViewTests: XCTestCase {
}
}
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,
@ -8857,6 +9401,59 @@ final class BrowserPanelHostContainerViewTests: XCTestCase {
XCTAssertGreaterThan(inspectorContainer.frame.minX, 92)
}
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),
@ -8922,6 +9519,47 @@ final class BrowserPanelHostContainerViewTests: XCTestCase {
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
@ -8940,6 +9578,76 @@ final class CmuxWebViewDragRoutingTests: XCTestCase {
}
}
#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<T>(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
)
)
}
}
#endif
@MainActor
final class BrowserPaneDropRoutingTests: XCTestCase {
func testVerticalZonesFollowAppKitCoordinates() {
@ -10025,6 +10733,7 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
)
}
@MainActor
func testKeyboardCopyModeIndicatorMountsAndUnmounts() {
let surface = TerminalSurface(
tabId: UUID(),
@ -10035,10 +10744,10 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
let hostedView = surface.hostedView
XCTAssertFalse(hostedView.debugHasKeyboardCopyModeIndicator())
hostedView.setKeyboardCopyModeIndicator(visible: true)
hostedView.syncKeyStateIndicator(text: "vim")
XCTAssertTrue(hostedView.debugHasKeyboardCopyModeIndicator())
hostedView.setKeyboardCopyModeIndicator(visible: false)
hostedView.syncKeyStateIndicator(text: nil)
XCTAssertFalse(hostedView.debugHasKeyboardCopyModeIndicator())
}
@ -10538,6 +11247,8 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase {
}
}
private final class WKInspectorProbeView: NSView {}
private func realizeWindowLayout(_ window: NSWindow) {
window.makeKeyAndOrderFront(nil)
window.displayIfNeeded()
@ -10802,6 +11513,145 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase {
XCTAssertEqual(webView.frame.size.height, slot.bounds.size.height, accuracy: 0.5)
}
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 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),