From ed7f6301d02a1f622fa01ebdfa42b9423883e3f0 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 18 Feb 2026 19:44:00 -0800 Subject: [PATCH 1/9] Improve terminal hosting depth and workspace mount policy --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + .../CmuxWebViewKeyEquivalentTests.swift | 76 +++++ Sources/AppDelegate.swift | 7 + Sources/ContentView.swift | 57 +++- Sources/GhosttyTerminalView.swift | 169 +++++----- Sources/TerminalController.swift | 86 +++-- Sources/TerminalWindowPortal.swift | 293 ++++++++++++++++++ tests/cmux.py | 33 +- tests/test_terminal_portal_hosting.py | 50 +++ 9 files changed, 651 insertions(+), 124 deletions(-) create mode 100644 Sources/TerminalWindowPortal.swift create mode 100644 tests/test_terminal_portal_hosting.py diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 64ef04be..bf2856d2 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ A5001003 /* TabManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001013 /* TabManager.swift */; }; A5001004 /* GhosttyConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001014 /* GhosttyConfig.swift */; }; A5001005 /* GhosttyTerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001015 /* GhosttyTerminalView.swift */; }; + A5001532 /* TerminalWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001531 /* TerminalWindowPortal.swift */; }; A5001006 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5001016 /* GhosttyKit.xcframework */; }; A5001007 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001019 /* TerminalController.swift */; }; A5001500 /* CmuxWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001510 /* CmuxWebView.swift */; }; @@ -132,6 +133,7 @@ A5001013 /* TabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManager.swift; sourceTree = ""; }; A5001014 /* GhosttyConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfig.swift; sourceTree = ""; }; A5001015 /* GhosttyTerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalView.swift; sourceTree = ""; }; + A5001531 /* TerminalWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindowPortal.swift; sourceTree = ""; }; A5001016 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = ""; }; A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = ""; }; A5001018 /* cmux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "cmux-Bridging-Header.h"; sourceTree = ""; }; @@ -304,6 +306,7 @@ A5001417 /* WorkspaceContentView.swift */, A5001014 /* GhosttyConfig.swift */, A5001015 /* GhosttyTerminalView.swift */, + A5001531 /* TerminalWindowPortal.swift */, A5001019 /* TerminalController.swift */, A5001225 /* SocketControlSettings.swift */, A5001090 /* AppDelegate.swift */, @@ -528,6 +531,7 @@ A5001407 /* WorkspaceContentView.swift in Sources */, A5001004 /* GhosttyConfig.swift in Sources */, A5001005 /* GhosttyTerminalView.swift in Sources */, + A5001532 /* TerminalWindowPortal.swift in Sources */, A5001007 /* TerminalController.swift in Sources */, A5001226 /* SocketControlSettings.swift in Sources */, A5001093 /* AppDelegate.swift in Sources */, diff --git a/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift b/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift index 6afb2a27..ba6ffd9e 100644 --- a/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift +++ b/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift @@ -1687,3 +1687,79 @@ final class MenuBarIconRendererTests: XCTestCase { XCTAssertEqual(withBadge.size.width, 18, accuracy: 0.001) } } + +final class WorkspaceMountPolicyTests: XCTestCase { + func testDefaultPolicyMountsOnlySelectedWorkspace() { + let a = UUID() + let b = UUID() + let existing: Set = [a, b] + + let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( + current: [a], + selected: b, + existing: existing + ) + + XCTAssertEqual(next, [b]) + } + + func testSelectedWorkspaceMovesToFrontAndMountCountIsBounded() { + let a = UUID() + let b = UUID() + let c = UUID() + let existing: Set = [a, b, c] + + let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( + current: [a, b, c], + selected: c, + existing: existing, + maxMounted: 2 + ) + + XCTAssertEqual(next, [c, a]) + } + + func testMissingWorkspacesArePruned() { + let a = UUID() + let b = UUID() + + let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( + current: [b, a], + selected: nil, + existing: [a], + maxMounted: 2 + ) + + XCTAssertEqual(next, [a]) + } + + func testSelectedWorkspaceIsInsertedWhenAbsentFromCurrentCache() { + let a = UUID() + let b = UUID() + let existing: Set = [a, b] + + let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( + current: [a], + selected: b, + existing: existing, + maxMounted: 2 + ) + + XCTAssertEqual(next, [b, a]) + } + + func testMaxMountedIsClampedToAtLeastOne() { + let a = UUID() + let b = UUID() + let existing: Set = [a, b] + + let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( + current: [a, b], + selected: nil, + existing: existing, + maxMounted: 0 + ) + + XCTAssertEqual(next, [a]) + } +} diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 26a2024b..0b594126 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1433,6 +1433,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard let self else { return event } if event.type == .keyDown { #if DEBUG + if (ProcessInfo.processInfo.environment["CMUX_KEY_LATENCY_PROBE"] == "1" + || UserDefaults.standard.bool(forKey: "cmuxKeyLatencyProbe")), + event.timestamp > 0 { + let delayMs = max(0, (ProcessInfo.processInfo.systemUptime - event.timestamp) * 1000) + let delayText = String(format: "%.2f", delayMs) + dlog("key.latency path=appMonitor ms=\(delayText) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue) repeat=\(event.isARepeat ? 1 : 0)") + } let frType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" dlog("monitor.keyDown: \(NSWindow.keyDescription(event)) fr=\(frType) addrBarId=\(self.browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil")") #endif diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index beed69a2..584d8cab 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -246,8 +246,13 @@ final class FileDropOverlayView: NSView { return .copy } - /// Temporarily hides self, hit-tests the window to find the GhosttyNSView under the cursor. - private func terminalUnderPoint(_ windowPoint: NSPoint) -> GhosttyNSView? { + /// Hit-tests the window to find the GhosttyNSView under the cursor. + func terminalUnderPoint(_ windowPoint: NSPoint) -> GhosttyNSView? { + if let window, + let portalTerminal = TerminalWindowPortalRegistry.terminalViewAtWindowPoint(windowPoint, in: window) { + return portalTerminal + } + guard let window, let contentView = window.contentView, let themeFrame = contentView.superview else { return nil } isHidden = true @@ -266,6 +271,32 @@ final class FileDropOverlayView: NSView { var fileDropOverlayKey: UInt8 = 0 +enum WorkspaceMountPolicy { + // Keep only the selected workspace mounted to minimize layer-tree traversal. + static let maxMountedWorkspaces = 1 + + static func nextMountedWorkspaceIds( + current: [UUID], + selected: UUID?, + existing: Set, + maxMounted: Int = maxMountedWorkspaces + ) -> [UUID] { + let clampedMax = max(1, maxMounted) + var ordered = current.filter { existing.contains($0) } + + if let selected, existing.contains(selected) { + ordered.removeAll { $0 == selected } + ordered.insert(selected, at: 0) + } + + if ordered.count > clampedMax { + ordered.removeSubrange(clampedMax...) + } + + return ordered + } +} + /// 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, @@ -306,6 +337,7 @@ struct ContentView: View { @State private var isResizerDragging = false private let sidebarHandleWidth: CGFloat = 6 @State private var selectedTabIds: Set = [] + @State private var mountedWorkspaceIds: [UUID] = [] @State private var lastSidebarSelectionIndex: Int? = nil @State private var titlebarText: String = "" @State private var isFullScreen: Bool = false @@ -384,9 +416,12 @@ struct ContentView: View { @State private var titlebarPadding: CGFloat = 32 private var terminalContent: some View { - ZStack { + let mountedWorkspaceIdSet = Set(mountedWorkspaceIds) + let mountedWorkspaces = tabManager.tabs.filter { mountedWorkspaceIdSet.contains($0.id) } + + return ZStack { ZStack { - ForEach(tabManager.tabs) { tab in + ForEach(mountedWorkspaces) { tab in let isActive = tabManager.selectedTabId == tab.id WorkspaceContentView(workspace: tab, isTabActive: isActive) .opacity(isActive ? 1 : 0) @@ -554,6 +589,7 @@ struct ContentView: View { .background(Color.clear) .onAppear { tabManager.applyWindowBackgroundForSelectedTab() + reconcileMountedWorkspaceIds() if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId { selectedTabIds = [selectedId] lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } @@ -562,6 +598,7 @@ struct ContentView: View { } .onChange(of: tabManager.selectedTabId) { newValue in tabManager.applyWindowBackgroundForSelectedTab() + reconcileMountedWorkspaceIds(selectedId: newValue) guard let newValue else { return } if selectedTabIds.count <= 1 { selectedTabIds = [newValue] @@ -585,6 +622,7 @@ struct ContentView: View { } .onReceive(tabManager.$tabs) { tabs in let existingIds = Set(tabs.map { $0.id }) + reconcileMountedWorkspaceIds(tabs: tabs) selectedTabIds = selectedTabIds.filter { existingIds.contains($0) } if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId { selectedTabIds = [selectedId] @@ -688,6 +726,17 @@ struct ContentView: View { }) } + private func reconcileMountedWorkspaceIds(tabs: [Workspace]? = nil, selectedId: UUID? = nil) { + let currentTabs = tabs ?? tabManager.tabs + let existing = Set(currentTabs.map { $0.id }) + let effectiveSelectedId = selectedId ?? tabManager.selectedTabId + mountedWorkspaceIds = WorkspaceMountPolicy.nextMountedWorkspaceIds( + current: mountedWorkspaceIds, + selected: effectiveSelectedId, + existing: existing + ) + } + private func addTab() { tabManager.addTab() sidebarSelectionState.selection = .tabs diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 0ab3ad3b..20f5ac7f 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1522,6 +1522,14 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { var backgroundColor: NSColor? private var keySequence: [ghostty_input_trigger_s] = [] private var keyTables: [String] = [] +#if DEBUG + private static let keyLatencyProbeEnabled: Bool = { + if ProcessInfo.processInfo.environment["CMUX_KEY_LATENCY_PROBE"] == "1" { + return true + } + return UserDefaults.standard.bool(forKey: "cmuxKeyLatencyProbe") + }() +#endif private var eventMonitor: Any? private var trackingArea: NSTrackingArea? private var windowObserver: NSObjectProtocol? @@ -1867,6 +1875,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private var markedText = NSMutableAttributedString() private var lastPerformKeyEvent: TimeInterval? +#if DEBUG + private func recordKeyLatency(path: String, event: NSEvent) { + guard Self.keyLatencyProbeEnabled else { return } + guard event.timestamp > 0 else { return } + let delayMs = max(0, (CACurrentMediaTime() - event.timestamp) * 1000) + let delayText = String(format: "%.2f", delayMs) + dlog("key.latency path=\(path) ms=\(delayText) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue) repeat=\(event.isARepeat ? 1 : 0)") + } +#endif + // Prevents NSBeep for unimplemented actions from interpretKeyEvents override func doCommand(by selector: Selector) { // Intentionally empty - prevents system beep on unhandled key commands @@ -1877,6 +1895,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { guard let fr = window?.firstResponder as? NSView, fr === self || fr.isDescendant(of: self) else { return false } guard let surface = ensureSurfaceReadyForInput() else { return false } +#if DEBUG + recordKeyLatency(path: "performKeyEquivalent", event: event) +#endif #if DEBUG cmuxWriteChildExitProbe( @@ -1986,6 +2007,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { super.keyDown(with: event) return } +#if DEBUG + recordKeyLatency(path: "keyDown", event: event) +#endif #if DEBUG cmuxWriteChildExitProbe( @@ -2930,6 +2954,7 @@ final class GhosttySurfaceScrollView: NSView { func setVisibleInUI(_ visible: Bool) { surfaceView.setVisibleInUI(visible) + isHidden = !visible if !visible { // If we were focused, yield first responder. if let window, let fr = window.firstResponder as? NSView, @@ -3578,22 +3603,38 @@ struct GhosttyTerminalView: NSViewRepresentable { var onFocus: ((UUID) -> Void)? = nil var onTriggerFlash: (() -> Void)? = nil - /// SwiftUI can create NSViewRepresentable containers that are not yet inserted into a - /// window (or never inserted at all) during bonsplit structural updates. We must avoid - /// re-parenting the hosted terminal view into an off-window container, since it can get - /// "stuck" there and leave the visible terminal blank/frozen. private final class HostContainerView: NSView { var onDidMoveToWindow: (() -> Void)? + var onGeometryChanged: (() -> Void)? override func viewDidMoveToWindow() { super.viewDidMoveToWindow() - guard window != nil else { return } onDidMoveToWindow?() + onGeometryChanged?() + } + + override func viewDidMoveToSuperview() { + super.viewDidMoveToSuperview() + onGeometryChanged?() + } + + override func layout() { + super.layout() + onGeometryChanged?() + } + + override func setFrameOrigin(_ newOrigin: NSPoint) { + super.setFrameOrigin(newOrigin) + onGeometryChanged?() + } + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + onGeometryChanged?() } } final class Coordinator { - var constraints: [NSLayoutConstraint] = [] var attachGeneration: Int = 0 // Track the latest desired state so attach retries can re-apply focus after re-parenting. var desiredIsActive: Bool = true @@ -3606,57 +3647,15 @@ struct GhosttyTerminalView: NSViewRepresentable { func makeNSView(context: Context) -> NSView { let container = HostContainerView() - container.wantsLayer = true + container.wantsLayer = false return container } - private static func attachHostedView(_ hostedView: GhosttySurfaceScrollView, to host: NSView, coordinator: Coordinator) { - // Avoid implicit animations during reparenting and constraint updates. Even a single - // CoreAnimation scale/bounds animation can produce a 1-frame "blank" or stretched - // compositor frame when the IOSurface-backed layer is resized or moved. - NSAnimationContext.runAnimationGroup { ctx in - ctx.duration = 0 - ctx.allowsImplicitAnimation = false - CATransaction.begin() - CATransaction.setDisableActions(true) - defer { CATransaction.commit() } - - // Remove any stale content views in the host, but avoid unnecessarily removing - // the hosted terminal view if it is already attached. - for v in host.subviews where v !== hostedView { - v.removeFromSuperview() - } - - if hostedView.superview !== host { - hostedView.removeFromSuperview() - host.addSubview(hostedView) - } - - hostedView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.deactivate(coordinator.constraints) - coordinator.constraints = [ - hostedView.leadingAnchor.constraint(equalTo: host.leadingAnchor), - hostedView.trailingAnchor.constraint(equalTo: host.trailingAnchor), - hostedView.topAnchor.constraint(equalTo: host.topAnchor), - hostedView.bottomAnchor.constraint(equalTo: host.bottomAnchor), - ] - NSLayoutConstraint.activate(coordinator.constraints) - host.needsLayout = true - host.layoutSubtreeIfNeeded() - } - - // Re-apply visible/active state after re-parenting so focus/occlusion requests run with - // a valid window. - // Without this, a focus attempt issued while the hosted view is off-window can time out, - // leaving the visible terminal unfocused (keys appear to go to the wrong surface). - hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI) - hostedView.setActive(coordinator.desiredIsActive) - } - func updateNSView(_ nsView: NSView, context: Context) { let hostedView = terminalSurface.hostedView - context.coordinator.desiredIsActive = isActive - context.coordinator.desiredIsVisibleInUI = isVisibleInUI + let coordinator = context.coordinator + coordinator.desiredIsActive = isActive + coordinator.desiredIsVisibleInUI = isVisibleInUI // Keep the surface lifecycle and handlers updated even if we defer re-parenting. hostedView.attachSurface(terminalSurface) @@ -3665,52 +3664,52 @@ struct GhosttyTerminalView: NSViewRepresentable { hostedView.setFocusHandler { onFocus?(terminalSurface.id) } hostedView.setTriggerFlashHandler(onTriggerFlash) - if hostedView.superview !== nsView { - context.coordinator.attachGeneration += 1 - let generation = context.coordinator.attachGeneration + coordinator.attachGeneration += 1 + let generation = coordinator.attachGeneration - // If this container isn't in a window yet, defer attaching until it is. - // Importantly: do NOT detach the hosted view from its current superview - // until we have a valid window, otherwise it can disappear and become - // "stuck" in an off-window container. - if let host = nsView as? HostContainerView { - host.onDidMoveToWindow = { [weak coordinator = context.coordinator, weak host, weak hostedView] in - guard let coordinator, coordinator.attachGeneration == generation else { return } - guard let host, let hostedView else { return } - guard host.window != nil else { return } - Self.attachHostedView(hostedView, to: host, coordinator: coordinator) - } + if let host = nsView as? HostContainerView { + host.onDidMoveToWindow = { [weak host, weak hostedView, weak coordinator] in + guard let host, let hostedView, let coordinator else { return } + guard coordinator.attachGeneration == generation else { return } + guard host.window != nil else { return } + TerminalWindowPortalRegistry.bind( + hostedView: hostedView, + to: host, + visibleInUI: coordinator.desiredIsVisibleInUI + ) + hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI) + hostedView.setActive(coordinator.desiredIsActive) + } + host.onGeometryChanged = { [weak host, weak hostedView, weak coordinator] in + guard let host, let hostedView, let coordinator else { return } + guard coordinator.attachGeneration == generation else { return } + TerminalWindowPortalRegistry.bind( + hostedView: hostedView, + to: host, + visibleInUI: coordinator.desiredIsVisibleInUI + ) + TerminalWindowPortalRegistry.synchronizeForAnchor(host) } - if nsView.window != nil { - Self.attachHostedView(hostedView, to: nsView, coordinator: context.coordinator) - } - } else { - context.coordinator.attachGeneration += 1 - if let host = nsView as? HostContainerView { - host.onDidMoveToWindow = nil + if host.window != nil { + TerminalWindowPortalRegistry.bind( + hostedView: hostedView, + to: host, + visibleInUI: coordinator.desiredIsVisibleInUI + ) + TerminalWindowPortalRegistry.synchronizeForAnchor(host) } } - } static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { coordinator.attachGeneration += 1 - NSLayoutConstraint.deactivate(coordinator.constraints) - coordinator.constraints.removeAll() - if let host = nsView as? HostContainerView { host.onDidMoveToWindow = nil + host.onGeometryChanged = nil } - // Avoid proactively detaching the hosted terminal view during SwiftUI structural updates. - // When bonsplit rearranges panes, SwiftUI can dismantle the "old" container before the - // "new" container has re-parented the hosted view; removing it here creates a visible - // transient blank (and can strand the view off-window if the re-attach is missed). - let hasHostedTerminal = nsView.subviews.contains(where: { $0 is GhosttySurfaceScrollView }) - if !hasHostedTerminal { - nsView.subviews.forEach { $0.removeFromSuperview() } - } + nsView.subviews.forEach { $0.removeFromSuperview() } } } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index c207d230..ac33bd0e 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -6291,6 +6291,10 @@ class TerminalController { guard let parsed = parseShortcutCombo(combo) else { return "ERROR: Invalid combo. Example: cmd+ctrl+h" } + + // Stamp at socket-handler arrival so event.timestamp includes any wait + // before the main-thread event dispatch. + let requestTimestamp = ProcessInfo.processInfo.systemUptime var result = "ERROR: Failed to create event" DispatchQueue.main.sync { @@ -6305,11 +6309,11 @@ class TerminalController { targetWindow.makeKeyAndOrderFront(nil) } let windowNumber = (NSApp.keyWindow ?? targetWindow)?.windowNumber ?? 0 - guard let event = NSEvent.keyEvent( + guard let keyDownEvent = NSEvent.keyEvent( with: .keyDown, location: .zero, modifierFlags: parsed.modifierFlags, - timestamp: ProcessInfo.processInfo.systemUptime, + timestamp: requestTimestamp, windowNumber: windowNumber, context: nil, characters: parsed.characters, @@ -6320,14 +6324,29 @@ class TerminalController { result = "ERROR: NSEvent.keyEvent returned nil" return } + let keyUpEvent = NSEvent.keyEvent( + with: .keyUp, + location: .zero, + modifierFlags: parsed.modifierFlags, + timestamp: requestTimestamp + 0.0001, + windowNumber: windowNumber, + context: nil, + characters: parsed.characters, + charactersIgnoringModifiers: parsed.charactersIgnoringModifiers, + isARepeat: false, + keyCode: parsed.keyCode + ) // Socket-driven shortcut simulation should reuse the exact same matching logic as the // app-level shortcut monitor (so tests are hermetic), while still falling back to the // normal responder chain for plain typing. - if let delegate = AppDelegate.shared, delegate.debugHandleCustomShortcut(event: event) { + if let delegate = AppDelegate.shared, delegate.debugHandleCustomShortcut(event: keyDownEvent) { result = "OK" return } - NSApp.sendEvent(event) + NSApp.sendEvent(keyDownEvent) + if let keyUpEvent { + NSApp.sendEvent(keyUpEvent) + } result = "OK" } return result @@ -6447,34 +6466,20 @@ class TerminalController { let contentView = window.contentView, let themeFrame = contentView.superview else { return } - // Compute the point in contentView's own coordinate system. - // NSHostingView is flipped: (0,0) = top-left, matching our API. - let contentPoint = NSPoint( - x: contentView.bounds.width * nx, - y: contentView.bounds.height * ny + // Convert normalized top-left coordinates into a window point. + let pointInTheme = NSPoint( + x: contentView.frame.minX + (contentView.bounds.width * nx), + y: contentView.frame.maxY - (contentView.bounds.height * ny) ) + let windowPoint = themeFrame.convert(pointInTheme, to: nil) - // hitTest expects the point in the receiver's superview's (themeFrame's) - // coordinate system. Use convert to handle the coordinate transform. - let hitPoint = contentView.convert(contentPoint, to: themeFrame) - - // Temporarily hide the overlay so it doesn't intercept the hit test. - let overlay = objc_getAssociatedObject(window, &fileDropOverlayKey) as? NSView - overlay?.isHidden = true - - let hitView = contentView.hitTest(hitPoint) - - overlay?.isHidden = false - - var current: NSView? = hitView - while let view = current { - if let terminal = view as? GhosttyNSView, - let surfaceId = terminal.terminalSurface?.id { - result = surfaceId.uuidString.uppercased() - return - } - current = view.superview + if let overlay = objc_getAssociatedObject(window, &fileDropOverlayKey) as? FileDropOverlayView, + let terminal = overlay.terminalUnderPoint(windowPoint), + let surfaceId = terminal.terminalSurface?.id { + result = surfaceId.uuidString.uppercased() + return } + result = "none" } return result @@ -9205,6 +9210,25 @@ class TerminalController { return "OK Refreshed \(refreshedCount) surfaces" } + private func viewDepth(of view: NSView, maxDepth: Int = 128) -> Int { + var depth = 0 + var current: NSView? = view + while let v = current, depth < maxDepth { + current = v.superview + depth += 1 + } + return depth + } + + private func isPortalHosted(_ view: NSView) -> Bool { + var current: NSView? = view + while let v = current { + if v is WindowTerminalHostView { return true } + current = v.superview + } + return false + } + private func surfaceHealth(_ tabArg: String) -> String { guard let tabManager = tabManager else { return "ERROR: TabManager not available" } var result = "" @@ -9219,7 +9243,9 @@ class TerminalController { let type = panel.panelType.rawValue if let tp = panel as? TerminalPanel { let inWindow = tp.surface.isViewInWindow - return "\(index): \(panelId) type=\(type) in_window=\(inWindow)" + let portalHosted = isPortalHosted(tp.hostedView) + let depth = viewDepth(of: tp.hostedView) + return "\(index): \(panelId) type=\(type) in_window=\(inWindow) portal=\(portalHosted) view_depth=\(depth)" } else if let bp = panel as? BrowserPanel { let inWindow = bp.webView.window != nil return "\(index): \(panelId) type=\(type) in_window=\(inWindow)" diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift new file mode 100644 index 00000000..6e70c32a --- /dev/null +++ b/Sources/TerminalWindowPortal.swift @@ -0,0 +1,293 @@ +import AppKit +import ObjectiveC + +private var cmuxWindowTerminalPortalKey: UInt8 = 0 + +final class WindowTerminalHostView: NSView { + override var isOpaque: Bool { false } +} + +@MainActor +final class WindowTerminalPortal: NSObject { + private weak var window: NSWindow? + private let hostView = WindowTerminalHostView(frame: .zero) + private weak var installedContainerView: NSView? + private weak var installedReferenceView: NSView? + private var installConstraints: [NSLayoutConstraint] = [] + + private struct Entry { + weak var hostedView: GhosttySurfaceScrollView? + weak var anchorView: NSView? + var visibleInUI: Bool + } + + private var entriesByHostedId: [ObjectIdentifier: Entry] = [:] + private var hostedByAnchorId: [ObjectIdentifier: ObjectIdentifier] = [:] + + init(window: NSWindow) { + self.window = window + super.init() + hostView.wantsLayer = false + hostView.translatesAutoresizingMaskIntoConstraints = false + _ = ensureInstalled() + } + + @discardableResult + private func ensureInstalled() -> Bool { + guard let window else { return false } + guard let (container, reference) = installationTarget(for: window) else { return false } + + if hostView.superview !== container || + installedContainerView !== container || + installedReferenceView !== reference { + NSLayoutConstraint.deactivate(installConstraints) + installConstraints.removeAll() + + hostView.removeFromSuperview() + container.addSubview(hostView, positioned: .above, relativeTo: reference) + + installConstraints = [ + hostView.leadingAnchor.constraint(equalTo: reference.leadingAnchor), + hostView.trailingAnchor.constraint(equalTo: reference.trailingAnchor), + hostView.topAnchor.constraint(equalTo: reference.topAnchor), + hostView.bottomAnchor.constraint(equalTo: reference.bottomAnchor), + ] + NSLayoutConstraint.activate(installConstraints) + installedContainerView = container + installedReferenceView = reference + } else { + container.addSubview(hostView, positioned: .above, relativeTo: reference) + } + + // Keep the drag/mouse forwarding overlay above portal-hosted terminal views. + if let overlay = objc_getAssociatedObject(window, &fileDropOverlayKey) as? NSView, + overlay.superview === container { + container.addSubview(overlay, positioned: .above, relativeTo: hostView) + } + + return true + } + + private func installationTarget(for window: NSWindow) -> (container: NSView, reference: NSView)? { + guard let contentView = window.contentView else { return nil } + + // If NSGlassEffectView wraps the original content view, install inside the glass view + // so terminals are above the glass background but below SwiftUI content. + if contentView.className == "NSGlassEffectView", + let foreground = contentView.subviews.first(where: { $0 !== hostView }) { + return (contentView, foreground) + } + + guard let themeFrame = contentView.superview else { return nil } + return (themeFrame, contentView) + } + + private static func isHiddenOrAncestorHidden(_ view: NSView) -> Bool { + if view.isHidden { return true } + var current = view.superview + while let v = current { + if v.isHidden { return true } + current = v.superview + } + return false + } + + private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> Bool { + abs(lhs.origin.x - rhs.origin.x) <= epsilon && + abs(lhs.origin.y - rhs.origin.y) <= epsilon && + abs(lhs.size.width - rhs.size.width) <= epsilon && + abs(lhs.size.height - rhs.size.height) <= epsilon + } + + func detachHostedView(withId hostedId: ObjectIdentifier) { + guard let entry = entriesByHostedId.removeValue(forKey: hostedId) else { return } + if let anchor = entry.anchorView { + hostedByAnchorId.removeValue(forKey: ObjectIdentifier(anchor)) + } + if let hostedView = entry.hostedView, hostedView.superview === hostView { + hostedView.removeFromSuperview() + } + } + + func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool) { + guard ensureInstalled() else { return } + + let hostedId = ObjectIdentifier(hostedView) + let anchorId = ObjectIdentifier(anchorView) + + if let previousHostedId = hostedByAnchorId[anchorId], previousHostedId != hostedId { + detachHostedView(withId: previousHostedId) + } + + if let oldEntry = entriesByHostedId[hostedId], + let oldAnchor = oldEntry.anchorView, + oldAnchor !== anchorView { + hostedByAnchorId.removeValue(forKey: ObjectIdentifier(oldAnchor)) + } + + hostedByAnchorId[anchorId] = hostedId + entriesByHostedId[hostedId] = Entry( + hostedView: hostedView, + anchorView: anchorView, + visibleInUI: visibleInUI + ) + + if hostedView.superview !== hostView { + hostedView.removeFromSuperview() + hostView.addSubview(hostedView) + } + + synchronizeHostedView(withId: hostedId) + pruneDeadEntries() + } + + func synchronizeHostedViewForAnchor(_ anchorView: NSView) { + guard let hostedId = hostedByAnchorId[ObjectIdentifier(anchorView)] else { return } + synchronizeHostedView(withId: hostedId) + } + + private func synchronizeHostedView(withId hostedId: ObjectIdentifier) { + guard ensureInstalled() else { return } + guard let entry = entriesByHostedId[hostedId] else { return } + guard let hostedView = entry.hostedView else { + entriesByHostedId.removeValue(forKey: hostedId) + return + } + guard let anchorView = entry.anchorView, let window else { + hostedView.isHidden = true + return + } + guard anchorView.window === window else { + hostedView.isHidden = true + return + } + + let frameInWindow = anchorView.convert(anchorView.bounds, to: nil) + let frameInHost = hostView.convert(frameInWindow, from: nil) + let shouldHide = + !entry.visibleInUI || + Self.isHiddenOrAncestorHidden(anchorView) || + frameInHost.width <= 1 || + frameInHost.height <= 1 + + let oldFrame = hostedView.frame + if !Self.rectApproximatelyEqual(oldFrame, frameInHost) { + CATransaction.begin() + CATransaction.setDisableActions(true) + hostedView.frame = frameInHost + CATransaction.commit() + + if abs(oldFrame.size.width - frameInHost.size.width) > 0.5 || + abs(oldFrame.size.height - frameInHost.size.height) > 0.5 { + hostedView.reconcileGeometryNow() + } + } + + if hostedView.isHidden != shouldHide { + hostedView.isHidden = shouldHide + } + } + + private func pruneDeadEntries() { + let deadHostedIds = entriesByHostedId.compactMap { hostedId, entry -> ObjectIdentifier? in + if entry.hostedView == nil { + if let anchor = entry.anchorView { + hostedByAnchorId.removeValue(forKey: ObjectIdentifier(anchor)) + } + return hostedId + } + if entry.anchorView == nil { + entry.hostedView?.isHidden = true + } + return nil + } + + for hostedId in deadHostedIds { + entriesByHostedId.removeValue(forKey: hostedId) + } + + let validAnchorIds = Set(entriesByHostedId.compactMap { _, entry in + entry.anchorView.map { ObjectIdentifier($0) } + }) + hostedByAnchorId = hostedByAnchorId.filter { validAnchorIds.contains($0.key) } + } + + func viewAtWindowPoint(_ windowPoint: NSPoint) -> NSView? { + guard ensureInstalled() else { return nil } + let point = hostView.convert(windowPoint, from: nil) + + // Restrict hit-testing to currently mapped entries so stale detached views + // can't steal file-drop/mouse routing. + for subview in hostView.subviews.reversed() { + guard let hostedView = subview as? GhosttySurfaceScrollView else { continue } + let hostedId = ObjectIdentifier(hostedView) + guard entriesByHostedId[hostedId] != nil else { continue } + guard !hostedView.isHidden else { continue } + guard hostedView.frame.contains(point) else { continue } + let localPoint = hostedView.convert(point, from: hostView) + return hostedView.hitTest(localPoint) ?? hostedView + } + + return nil + } + + func terminalViewAtWindowPoint(_ windowPoint: NSPoint) -> GhosttyNSView? { + guard let hitView = viewAtWindowPoint(windowPoint) else { return nil } + var current: NSView? = hitView + while let view = current { + if let terminal = view as? GhosttyNSView { return terminal } + current = view.superview + } + return nil + } +} + +@MainActor +enum TerminalWindowPortalRegistry { + private static var portalsByWindowId: [ObjectIdentifier: WindowTerminalPortal] = [:] + private static var hostedToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:] + + private static func portal(for window: NSWindow) -> WindowTerminalPortal { + if let existing = objc_getAssociatedObject(window, &cmuxWindowTerminalPortalKey) as? WindowTerminalPortal { + portalsByWindowId[ObjectIdentifier(window)] = existing + return existing + } + + let portal = WindowTerminalPortal(window: window) + objc_setAssociatedObject(window, &cmuxWindowTerminalPortalKey, portal, .OBJC_ASSOCIATION_RETAIN) + portalsByWindowId[ObjectIdentifier(window)] = portal + return portal + } + + static func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool) { + guard let window = anchorView.window else { return } + + let windowId = ObjectIdentifier(window) + let hostedId = ObjectIdentifier(hostedView) + let nextPortal = portal(for: window) + + if let oldWindowId = hostedToWindowId[hostedId], + oldWindowId != windowId { + portalsByWindowId[oldWindowId]?.detachHostedView(withId: hostedId) + } + + nextPortal.bind(hostedView: hostedView, to: anchorView, visibleInUI: visibleInUI) + hostedToWindowId[hostedId] = windowId + } + + static func synchronizeForAnchor(_ anchorView: NSView) { + guard let window = anchorView.window else { return } + let portal = portal(for: window) + portal.synchronizeHostedViewForAnchor(anchorView) + } + + static func viewAtWindowPoint(_ windowPoint: NSPoint, in window: NSWindow) -> NSView? { + let portal = portal(for: window) + return portal.viewAtWindowPoint(windowPoint) + } + + static func terminalViewAtWindowPoint(_ windowPoint: NSPoint, in window: NSWindow) -> GhosttyNSView? { + let portal = portal(for: window) + return portal.terminalViewAtWindowPoint(windowPoint) + } +} diff --git a/tests/cmux.py b/tests/cmux.py index 22d61fa1..d33025b6 100755 --- a/tests/cmux.py +++ b/tests/cmux.py @@ -997,7 +997,8 @@ class cmux: def surface_health(self, workspace: Union[str, int, None] = None) -> List[dict]: """ Check view health of all surfaces in a workspace. - Returns list of dicts with keys: index, id, type, in_window. + Returns list of dicts with keys: index, id, type, in_window, plus any + extra key=value fields returned by the daemon. """ arg = "" if workspace is None else str(workspace) response = self._send_command(f"surface_health {arg}".rstrip()) @@ -1013,14 +1014,36 @@ class cmux: continue index = int(parts[0].rstrip(":")) surface_id = parts[1] - panel_type = parts[2].split("=", 1)[1] if "=" in parts[2] else "unknown" - in_window = parts[3].split("=", 1)[1] == "true" if "=" in parts[3] else False - surfaces.append({ + kv: dict[str, str] = {} + for token in parts[2:]: + if "=" not in token: + continue + key, value = token.split("=", 1) + kv[key] = value + + panel_type = kv.get("type", "unknown") + in_window = kv.get("in_window", "false") == "true" + + row: dict = { "index": index, "id": surface_id, "type": panel_type, "in_window": in_window, - }) + } + + for key, value in kv.items(): + if key in {"type", "in_window"}: + continue + if value == "true": + row[key] = True + elif value == "false": + row[key] = False + elif value.isdigit() or (value.startswith("-") and value[1:].isdigit()): + row[key] = int(value) + else: + row[key] = value + + surfaces.append(row) return surfaces diff --git a/tests/test_terminal_portal_hosting.py b/tests/test_terminal_portal_hosting.py new file mode 100644 index 00000000..911a95b3 --- /dev/null +++ b/tests/test_terminal_portal_hosting.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Regression: terminal views should be portal-hosted near the window root. + +This catches regressions where terminal NSViews are reattached deep inside the SwiftUI +hierarchy, which increases Core Animation commit traversal depth and input latency. +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def main() -> int: + with cmux(SOCKET_PATH) as c: + c.activate_app() + + c.new_workspace() + time.sleep(0.2) + c.new_split("right") + time.sleep(0.8) + + health = c.surface_health() + terminals = [row for row in health if row.get("type") == "terminal"] + if len(terminals) < 2: + raise cmuxError(f"expected >=2 terminal surfaces after split, got={terminals}") + + for row in terminals: + if not row.get("in_window", False): + raise cmuxError(f"terminal not attached to window: {row}") + if row.get("portal") is not True: + raise cmuxError(f"terminal is not portal-hosted: {row}") + depth = row.get("view_depth") + if not isinstance(depth, int): + raise cmuxError(f"missing view_depth in surface_health: {row}") + if depth > 8: + raise cmuxError(f"terminal view depth too deep ({depth}): {row}") + + print("PASS: terminal surfaces are portal-hosted with shallow view depth") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From edadda6d8c7dab651d0deb8f2bdd08b736322b79 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 18 Feb 2026 20:05:49 -0800 Subject: [PATCH 2/9] Fix portal hit-testing and teardown visibility --- .../CmuxWebViewKeyEquivalentTests.swift | 24 +++++++++++++++++++ Sources/GhosttyTerminalView.swift | 8 +++++++ Sources/TerminalWindowPortal.swift | 5 ++++ 3 files changed, 37 insertions(+) diff --git a/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift b/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift index ba6ffd9e..ef2eb6f4 100644 --- a/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift +++ b/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift @@ -1763,3 +1763,27 @@ final class WorkspaceMountPolicyTests: XCTestCase { XCTAssertEqual(next, [a]) } } + +@MainActor +final class WindowTerminalHostViewTests: XCTestCase { + private final class CapturingView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(point) ? self : nil + } + } + + 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))) + } +} diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 20f5ac7f..24c17e85 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3639,6 +3639,7 @@ struct GhosttyTerminalView: NSViewRepresentable { // Track the latest desired state so attach retries can re-apply focus after re-parenting. var desiredIsActive: Bool = true var desiredIsVisibleInUI: Bool = true + weak var hostedView: GhosttySurfaceScrollView? } func makeCoordinator() -> Coordinator { @@ -3656,6 +3657,7 @@ struct GhosttyTerminalView: NSViewRepresentable { let coordinator = context.coordinator coordinator.desiredIsActive = isActive coordinator.desiredIsVisibleInUI = isVisibleInUI + coordinator.hostedView = hostedView // Keep the surface lifecycle and handlers updated even if we defer re-parenting. hostedView.attachSurface(terminalSurface) @@ -3704,12 +3706,18 @@ struct GhosttyTerminalView: NSViewRepresentable { static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { coordinator.attachGeneration += 1 + coordinator.desiredIsActive = false + coordinator.desiredIsVisibleInUI = false if let host = nsView as? HostContainerView { host.onDidMoveToWindow = nil host.onGeometryChanged = nil } + coordinator.hostedView?.setVisibleInUI(false) + coordinator.hostedView?.setActive(false) + coordinator.hostedView = nil + nsView.subviews.forEach { $0.removeFromSuperview() } } } diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 6e70c32a..aa60e937 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -5,6 +5,11 @@ private var cmuxWindowTerminalPortalKey: UInt8 = 0 final class WindowTerminalHostView: NSView { override var isOpaque: Bool { false } + + override func hitTest(_ point: NSPoint) -> NSView? { + let hitView = super.hitTest(point) + return hitView === self ? nil : hitView + } } @MainActor From 22aa4d48daa1712e955646f1b9f8a28321289037 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 18 Feb 2026 20:17:44 -0800 Subject: [PATCH 3/9] Fix portal lifecycle retention and add regression tests --- .../CmuxWebViewKeyEquivalentTests.swift | 54 +++++++++++ Sources/TerminalWindowPortal.swift | 97 +++++++++++++++++-- 2 files changed, 143 insertions(+), 8 deletions(-) diff --git a/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift b/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift index ef2eb6f4..d982f397 100644 --- a/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift +++ b/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift @@ -1787,3 +1787,57 @@ final class WindowTerminalHostViewTests: XCTestCase { XCTAssertNil(host.hitTest(NSPoint(x: 150, y: 100))) } } + +@MainActor +final class TerminalWindowPortalLifecycleTests: XCTestCase { + 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") + } +} diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index aa60e937..f71197e4 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -2,6 +2,7 @@ import AppKit import ObjectiveC private var cmuxWindowTerminalPortalKey: UInt8 = 0 +private var cmuxWindowTerminalPortalCloseObserverKey: UInt8 = 0 final class WindowTerminalHostView: NSView { override var isOpaque: Bool { false } @@ -147,6 +148,7 @@ final class WindowTerminalPortal: NSObject { } func synchronizeHostedViewForAnchor(_ anchorView: NSView) { + pruneDeadEntries() guard let hostedId = hostedByAnchorId[ObjectIdentifier(anchorView)] else { return } synchronizeHostedView(withId: hostedId) } @@ -194,21 +196,18 @@ final class WindowTerminalPortal: NSObject { } private func pruneDeadEntries() { + let currentWindow = window let deadHostedIds = entriesByHostedId.compactMap { hostedId, entry -> ObjectIdentifier? in - if entry.hostedView == nil { - if let anchor = entry.anchorView { - hostedByAnchorId.removeValue(forKey: ObjectIdentifier(anchor)) - } + guard entry.hostedView != nil else { return hostedId } + guard let anchor = entry.anchorView else { return hostedId } + if anchor.window !== currentWindow || anchor.superview == nil { return hostedId } - if entry.anchorView == nil { - entry.hostedView?.isHidden = true - } return nil } for hostedId in deadHostedIds { - entriesByHostedId.removeValue(forKey: hostedId) + detachHostedView(withId: hostedId) } let validAnchorIds = Set(entriesByHostedId.compactMap { _, entry in @@ -217,6 +216,31 @@ final class WindowTerminalPortal: NSObject { hostedByAnchorId = hostedByAnchorId.filter { validAnchorIds.contains($0.key) } } + func hostedIds() -> Set { + Set(entriesByHostedId.keys) + } + + func tearDown() { + for hostedId in Array(entriesByHostedId.keys) { + detachHostedView(withId: hostedId) + } + NSLayoutConstraint.deactivate(installConstraints) + installConstraints.removeAll() + hostView.removeFromSuperview() + installedContainerView = nil + installedReferenceView = nil + } + +#if DEBUG + func debugEntryCount() -> Int { + entriesByHostedId.count + } + + func debugHostedSubviewCount() -> Int { + hostView.subviews.count + } +#endif + func viewAtWindowPoint(_ windowPoint: NSPoint) -> NSView? { guard ensureInstalled() else { return nil } let point = hostView.convert(windowPoint, from: nil) @@ -252,15 +276,65 @@ enum TerminalWindowPortalRegistry { private static var portalsByWindowId: [ObjectIdentifier: WindowTerminalPortal] = [:] private static var hostedToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:] + private static func installWindowCloseObserverIfNeeded(for window: NSWindow) { + guard objc_getAssociatedObject(window, &cmuxWindowTerminalPortalCloseObserverKey) == nil else { return } + let windowId = ObjectIdentifier(window) + let observer = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: window, + queue: .main + ) { [weak window] _ in + MainActor.assumeIsolated { + if let window { + removePortal(for: window) + } else { + removePortal(windowId: windowId, window: nil) + } + } + } + objc_setAssociatedObject( + window, + &cmuxWindowTerminalPortalCloseObserverKey, + observer, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + + private static func removePortal(for window: NSWindow) { + removePortal(windowId: ObjectIdentifier(window), window: window) + } + + private static func removePortal(windowId: ObjectIdentifier, window: NSWindow?) { + if let portal = portalsByWindowId.removeValue(forKey: windowId) { + portal.tearDown() + } + hostedToWindowId = hostedToWindowId.filter { $0.value != windowId } + + guard let window else { return } + if let observer = objc_getAssociatedObject(window, &cmuxWindowTerminalPortalCloseObserverKey) { + NotificationCenter.default.removeObserver(observer) + } + objc_setAssociatedObject(window, &cmuxWindowTerminalPortalCloseObserverKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + objc_setAssociatedObject(window, &cmuxWindowTerminalPortalKey, nil, .OBJC_ASSOCIATION_RETAIN) + } + + private static func pruneHostedMappings(for windowId: ObjectIdentifier, validHostedIds: Set) { + hostedToWindowId = hostedToWindowId.filter { hostedId, mappedWindowId in + mappedWindowId != windowId || validHostedIds.contains(hostedId) + } + } + private static func portal(for window: NSWindow) -> WindowTerminalPortal { if let existing = objc_getAssociatedObject(window, &cmuxWindowTerminalPortalKey) as? WindowTerminalPortal { portalsByWindowId[ObjectIdentifier(window)] = existing + installWindowCloseObserverIfNeeded(for: window) return existing } let portal = WindowTerminalPortal(window: window) objc_setAssociatedObject(window, &cmuxWindowTerminalPortalKey, portal, .OBJC_ASSOCIATION_RETAIN) portalsByWindowId[ObjectIdentifier(window)] = portal + installWindowCloseObserverIfNeeded(for: window) return portal } @@ -278,6 +352,7 @@ enum TerminalWindowPortalRegistry { nextPortal.bind(hostedView: hostedView, to: anchorView, visibleInUI: visibleInUI) hostedToWindowId[hostedId] = windowId + pruneHostedMappings(for: windowId, validHostedIds: nextPortal.hostedIds()) } static func synchronizeForAnchor(_ anchorView: NSView) { @@ -295,4 +370,10 @@ enum TerminalWindowPortalRegistry { let portal = portal(for: window) return portal.terminalViewAtWindowPoint(windowPoint) } + +#if DEBUG + static func debugPortalCount() -> Int { + portalsByWindowId.count + } +#endif } From b05347884df185744290fee1fc3877a50b0fc741 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 18 Feb 2026 20:24:54 -0800 Subject: [PATCH 4/9] Restore unfocused pane dimming for portal-hosted terminals --- .../CmuxWebViewKeyEquivalentTests.swift | 18 +++++++++++ Sources/GhosttyTerminalView.swift | 32 +++++++++++++++++++ Sources/Panels/TerminalPanelView.swift | 6 ++++ 3 files changed, 56 insertions(+) diff --git a/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift b/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift index d982f397..e083fb1a 100644 --- a/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift +++ b/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift @@ -1788,6 +1788,24 @@ final class WindowTerminalHostViewTests: XCTestCase { } } +@MainActor +final class GhosttySurfaceOverlayTests: XCTestCase { + 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) + } +} + @MainActor final class TerminalWindowPortalLifecycleTests: XCTestCase { func testRegistryPrunesPortalWhenWindowCloses() { diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 24c17e85..b56bcd6b 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2657,6 +2657,7 @@ final class GhosttySurfaceScrollView: NSView { private let scrollView: GhosttyScrollView private let documentView: NSView private let surfaceView: GhosttyNSView + private let inactiveOverlayView: GhosttyFlashOverlayView private let flashOverlayView: GhosttyFlashOverlayView private let flashLayer: CAShapeLayer private var observers: [NSObjectProtocol] = [] @@ -2739,6 +2740,7 @@ final class GhosttySurfaceScrollView: NSView { self.surfaceView = surfaceView backgroundView = NSView(frame: .zero) scrollView = GhosttyScrollView() + inactiveOverlayView = GhosttyFlashOverlayView(frame: .zero) flashOverlayView = GhosttyFlashOverlayView(frame: .zero) flashLayer = CAShapeLayer() scrollView.hasVerticalScroller = true @@ -2766,6 +2768,10 @@ final class GhosttySurfaceScrollView: NSView { .cgColor addSubview(backgroundView) addSubview(scrollView) + inactiveOverlayView.wantsLayer = true + inactiveOverlayView.layer?.backgroundColor = NSColor.clear.cgColor + inactiveOverlayView.isHidden = true + addSubview(inactiveOverlayView) flashOverlayView.wantsLayer = true flashOverlayView.layer?.backgroundColor = NSColor.clear.cgColor flashOverlayView.layer?.masksToBounds = false @@ -2878,6 +2884,7 @@ final class GhosttySurfaceScrollView: NSView { surfaceView.frame.size = targetSize surfaceView.pushTargetSurfaceSize(targetSize) documentView.frame.size.width = scrollView.bounds.width + inactiveOverlayView.frame = bounds flashOverlayView.frame = bounds updateFlashPath() synchronizeScrollView() @@ -2927,6 +2934,15 @@ final class GhosttySurfaceScrollView: NSView { CATransaction.commit() } + func setInactiveOverlay(color: NSColor, opacity: CGFloat, visible: Bool) { + let clampedOpacity = max(0, min(1, opacity)) + CATransaction.begin() + CATransaction.setDisableActions(true) + inactiveOverlayView.layer?.backgroundColor = color.withAlphaComponent(clampedOpacity).cgColor + inactiveOverlayView.isHidden = !(visible && clampedOpacity > 0.0001) + CATransaction.commit() + } + func triggerFlash() { DispatchQueue.main.async { [weak self] in guard let self else { return } @@ -3011,6 +3027,13 @@ final class GhosttySurfaceScrollView: NSView { surfaceView.debugRegisteredDropTypes() } + func debugInactiveOverlayState() -> (isHidden: Bool, alpha: CGFloat) { + ( + inactiveOverlayView.isHidden, + inactiveOverlayView.layer?.backgroundColor.flatMap { NSColor(cgColor: $0)?.alphaComponent } ?? 0 + ) + } + #endif /// Handle file/URL drops, forwarding to the terminal as shell-escaped paths. @@ -3599,6 +3622,9 @@ struct GhosttyTerminalView: NSViewRepresentable { let terminalSurface: TerminalSurface var isActive: Bool = true var isVisibleInUI: Bool = true + var showsInactiveOverlay: Bool = false + var inactiveOverlayColor: NSColor = .clear + var inactiveOverlayOpacity: Double = 0 var reattachToken: UInt64 = 0 var onFocus: ((UUID) -> Void)? = nil var onTriggerFlash: (() -> Void)? = nil @@ -3663,6 +3689,11 @@ struct GhosttyTerminalView: NSViewRepresentable { hostedView.attachSurface(terminalSurface) hostedView.setVisibleInUI(isVisibleInUI) hostedView.setActive(isActive) + hostedView.setInactiveOverlay( + color: inactiveOverlayColor, + opacity: CGFloat(inactiveOverlayOpacity), + visible: showsInactiveOverlay + ) hostedView.setFocusHandler { onFocus?(terminalSurface.id) } hostedView.setTriggerFlashHandler(onTriggerFlash) @@ -3716,6 +3747,7 @@ struct GhosttyTerminalView: NSViewRepresentable { coordinator.hostedView?.setVisibleInUI(false) coordinator.hostedView?.setActive(false) + coordinator.hostedView?.setInactiveOverlay(color: .clear, opacity: 0, visible: false) coordinator.hostedView = nil nsView.subviews.forEach { $0.removeFromSuperview() } diff --git a/Sources/Panels/TerminalPanelView.swift b/Sources/Panels/TerminalPanelView.swift index 5628c487..f0e60142 100644 --- a/Sources/Panels/TerminalPanelView.swift +++ b/Sources/Panels/TerminalPanelView.swift @@ -1,5 +1,6 @@ import SwiftUI import Foundation +import AppKit /// View for rendering a terminal panel struct TerminalPanelView: View { @@ -18,6 +19,9 @@ struct TerminalPanelView: View { terminalSurface: panel.surface, isActive: isFocused, isVisibleInUI: isVisibleInUI, + showsInactiveOverlay: isSplit && !isFocused, + inactiveOverlayColor: appearance.unfocusedOverlayNSColor, + inactiveOverlayOpacity: appearance.unfocusedOverlayOpacity, reattachToken: panel.viewReattachToken, onFocus: { _ in onFocus() }, onTriggerFlash: onTriggerFlash @@ -63,12 +67,14 @@ struct TerminalPanelView: View { struct PanelAppearance { let dividerColor: Color let unfocusedOverlayColor: Color + let unfocusedOverlayNSColor: NSColor let unfocusedOverlayOpacity: Double static func fromConfig(_ config: GhosttyConfig) -> PanelAppearance { PanelAppearance( dividerColor: Color(nsColor: config.resolvedSplitDividerColor), unfocusedOverlayColor: Color(nsColor: config.unfocusedSplitOverlayFill), + unfocusedOverlayNSColor: config.unfocusedSplitOverlayFill, unfocusedOverlayOpacity: config.unfocusedSplitOverlayOpacity ) } From 27df9cd171af3ccc27cc31ab10d8d055706b724d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 18 Feb 2026 20:28:00 -0800 Subject: [PATCH 5/9] Remove SwiftUI dimming fallback for portal terminals --- Sources/Panels/TerminalPanelView.swift | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/Sources/Panels/TerminalPanelView.swift b/Sources/Panels/TerminalPanelView.swift index f0e60142..eaf2f0ba 100644 --- a/Sources/Panels/TerminalPanelView.swift +++ b/Sources/Panels/TerminalPanelView.swift @@ -31,14 +31,6 @@ struct TerminalPanelView: View { .id(panel.id) .background(Color.clear) - // Unfocused overlay - if isSplit && !isFocused && appearance.unfocusedOverlayOpacity > 0 { - Rectangle() - .fill(appearance.unfocusedOverlayColor) - .opacity(appearance.unfocusedOverlayOpacity) - .allowsHitTesting(false) - } - // Unread notification indicator if notificationStore.hasUnreadNotification(forTabId: panel.workspaceId, surfaceId: panel.id) { Rectangle() @@ -66,14 +58,12 @@ struct TerminalPanelView: View { /// Shared appearance settings for panels struct PanelAppearance { let dividerColor: Color - let unfocusedOverlayColor: Color let unfocusedOverlayNSColor: NSColor let unfocusedOverlayOpacity: Double static func fromConfig(_ config: GhosttyConfig) -> PanelAppearance { PanelAppearance( dividerColor: Color(nsColor: config.resolvedSplitDividerColor), - unfocusedOverlayColor: Color(nsColor: config.unfocusedSplitOverlayFill), unfocusedOverlayNSColor: config.unfocusedSplitOverlayFill, unfocusedOverlayOpacity: config.unfocusedSplitOverlayOpacity ) From a723bbaa6a0d3aa4a964366e7ba7af1966d3771a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 18 Feb 2026 20:33:35 -0800 Subject: [PATCH 6/9] Fix Finder file drop routing for portal-hosted terminals --- .../CmuxWebViewKeyEquivalentTests.swift | 29 +++++++++++++++++++ Sources/GhosttyTerminalView.swift | 5 ++++ Sources/TerminalWindowPortal.swift | 19 ++++++++---- 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift b/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift index e083fb1a..daf5706a 100644 --- a/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift +++ b/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift @@ -1858,4 +1858,33 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { 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 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" + ) + } } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index b56bcd6b..a0d4bc88 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3049,6 +3049,11 @@ final class GhosttySurfaceScrollView: NSView { return true } + func terminalViewForDrop(at point: NSPoint) -> GhosttyNSView? { + guard bounds.contains(point), !isHidden else { return nil } + return surfaceView + } + #if DEBUG /// Sends a synthetic Ctrl+D key press directly to the surface view. /// This exercises the same key path as real keyboard input (ghostty_surface_key), diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index f71197e4..7faf6949 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -261,12 +261,21 @@ final class WindowTerminalPortal: NSObject { } func terminalViewAtWindowPoint(_ windowPoint: NSPoint) -> GhosttyNSView? { - guard let hitView = viewAtWindowPoint(windowPoint) else { return nil } - var current: NSView? = hitView - while let view = current { - if let terminal = view as? GhosttyNSView { return terminal } - current = view.superview + guard ensureInstalled() else { return nil } + let point = hostView.convert(windowPoint, from: nil) + + for subview in hostView.subviews.reversed() { + guard let hostedView = subview as? GhosttySurfaceScrollView else { continue } + let hostedId = ObjectIdentifier(hostedView) + guard entriesByHostedId[hostedId] != nil else { continue } + guard !hostedView.isHidden else { continue } + guard hostedView.frame.contains(point) else { continue } + let localPoint = hostedView.convert(point, from: hostView) + if let terminal = hostedView.terminalViewForDrop(at: localPoint) { + return terminal + } } + return nil } } From 7aa80b9cdc3e14d488e3375c3d3c3742c65ec937 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:17:53 -0800 Subject: [PATCH 7/9] Stabilize rapid workspace switching handoff --- .../CmuxWebViewKeyEquivalentTests.swift | 113 +++++++- Sources/AppDelegate.swift | 12 + Sources/ContentView.swift | 220 +++++++++++++++- Sources/GhosttyTerminalView.swift | 74 ++++++ Sources/TabManager.swift | 241 +++++++++++++++++- Sources/WorkspaceContentView.swift | 13 +- scripts/test-unit.sh | 21 ++ 7 files changed, 664 insertions(+), 30 deletions(-) create mode 100755 scripts/test-unit.sh diff --git a/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift b/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift index daf5706a..3bb81093 100644 --- a/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift +++ b/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift @@ -426,6 +426,35 @@ final class WorkspaceReorderTests: XCTestCase { } } +@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() { @@ -1692,12 +1721,15 @@ final class WorkspaceMountPolicyTests: XCTestCase { func testDefaultPolicyMountsOnlySelectedWorkspace() { let a = UUID() let b = UUID() - let existing: Set = [a, b] + let orderedTabIds: [UUID] = [a, b] let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( current: [a], selected: b, - existing: existing + pinnedIds: [], + orderedTabIds: orderedTabIds, + isCycleHot: false, + maxMounted: WorkspaceMountPolicy.maxMountedWorkspaces ) XCTAssertEqual(next, [b]) @@ -1707,12 +1739,14 @@ final class WorkspaceMountPolicyTests: XCTestCase { let a = UUID() let b = UUID() let c = UUID() - let existing: Set = [a, b, c] + let orderedTabIds: [UUID] = [a, b, c] let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( current: [a, b, c], selected: c, - existing: existing, + pinnedIds: [], + orderedTabIds: orderedTabIds, + isCycleHot: false, maxMounted: 2 ) @@ -1726,7 +1760,9 @@ final class WorkspaceMountPolicyTests: XCTestCase { let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( current: [b, a], selected: nil, - existing: [a], + pinnedIds: [], + orderedTabIds: [a], + isCycleHot: false, maxMounted: 2 ) @@ -1736,12 +1772,14 @@ final class WorkspaceMountPolicyTests: XCTestCase { func testSelectedWorkspaceIsInsertedWhenAbsentFromCurrentCache() { let a = UUID() let b = UUID() - let existing: Set = [a, b] + let orderedTabIds: [UUID] = [a, b] let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( current: [a], selected: b, - existing: existing, + pinnedIds: [], + orderedTabIds: orderedTabIds, + isCycleHot: false, maxMounted: 2 ) @@ -1751,17 +1789,74 @@ final class WorkspaceMountPolicyTests: XCTestCase { func testMaxMountedIsClampedToAtLeastOne() { let a = UUID() let b = UUID() - let existing: Set = [a, b] + let orderedTabIds: [UUID] = [a, b] let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( current: [a, b], selected: nil, - existing: existing, + pinnedIds: [], + orderedTabIds: orderedTabIds, + isCycleHot: false, maxMounted: 0 ) XCTAssertEqual(next, [a]) } + + func testCycleHotModeWarmsSelectedAndImmediateNeighbors() { + 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, b, d]) + } + + 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, a]) + } + + 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]) + } } @MainActor diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 0b594126..66daff9c 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1751,11 +1751,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Workspace navigation: Cmd+Ctrl+] / Cmd+Ctrl+[ if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .nextSidebarTab)) { +#if DEBUG + let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "ws.shortcut dir=next repeat=\(event.isARepeat ? 1 : 0) keyCode=\(event.keyCode) selected=\(selected)" + ) +#endif tabManager?.selectNextTab() return true } if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .prevSidebarTab)) { +#if DEBUG + let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "ws.shortcut dir=prev repeat=\(event.isARepeat ? 1 : 0) keyCode=\(event.keyCode) selected=\(selected)" + ) +#endif tabManager?.selectPreviousTab() return true } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 584d8cab..f2d7bf6b 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -274,13 +274,17 @@ var fileDropOverlayKey: UInt8 = 0 enum WorkspaceMountPolicy { // Keep only the selected workspace mounted to minimize layer-tree traversal. static let maxMountedWorkspaces = 1 + static let maxMountedWorkspacesDuringCycle = 3 static func nextMountedWorkspaceIds( current: [UUID], selected: UUID?, - existing: Set, - maxMounted: Int = maxMountedWorkspaces + pinnedIds: Set, + orderedTabIds: [UUID], + isCycleHot: Bool, + maxMounted: Int ) -> [UUID] { + let existing = Set(orderedTabIds) let clampedMax = max(1, maxMounted) var ordered = current.filter { existing.contains($0) } @@ -289,12 +293,41 @@ enum WorkspaceMountPolicy { ordered.insert(selected, at: 0) } + let prioritizedPinnedIds = pinnedIds.filter { existing.contains($0) && $0 != selected } + for pinnedId in prioritizedPinnedIds.reversed() { + ordered.removeAll { $0 == pinnedId } + ordered.insert(pinnedId, at: 0) + } + + if isCycleHot, let selected { + let warmIds = cycleWarmIds(selected: selected, orderedTabIds: orderedTabIds) + for id in warmIds.reversed() { + ordered.removeAll { $0 == id } + ordered.insert(id, at: 0) + } + } + if ordered.count > clampedMax { ordered.removeSubrange(clampedMax...) } return ordered } + + private static func cycleWarmIds(selected: UUID, orderedTabIds: [UUID]) -> [UUID] { + guard let selectedIndex = orderedTabIds.firstIndex(of: selected) else { + return [selected] + } + + var ids: [UUID] = [selected] + if selectedIndex > 0 { + ids.append(orderedTabIds[selectedIndex - 1]) + } + if selectedIndex + 1 < orderedTabIds.count { + ids.append(orderedTabIds[selectedIndex + 1]) + } + return ids + } } /// Installs a FileDropOverlayView on the window's theme frame for Finder file drag support. @@ -343,6 +376,10 @@ struct ContentView: View { @State private var isFullScreen: Bool = false @State private var observedWindow: NSWindow? @StateObject private var fullscreenControlsViewModel = TitlebarControlsViewModel() + @State private var previousSelectedWorkspaceId: UUID? + @State private var retiringWorkspaceId: UUID? + @State private var workspaceHandoffGeneration: UInt64 = 0 + @State private var workspaceHandoffFallbackTask: Task? private var sidebarView: some View { VerticalTabsSidebar( @@ -418,14 +455,24 @@ struct ContentView: View { private var terminalContent: some View { let mountedWorkspaceIdSet = Set(mountedWorkspaceIds) let mountedWorkspaces = tabManager.tabs.filter { mountedWorkspaceIdSet.contains($0.id) } + let selectedWorkspaceId = tabManager.selectedTabId + let retiringWorkspaceId = self.retiringWorkspaceId return ZStack { ZStack { ForEach(mountedWorkspaces) { tab in - let isActive = tabManager.selectedTabId == tab.id - WorkspaceContentView(workspace: tab, isTabActive: isActive) - .opacity(isActive ? 1 : 0) - .allowsHitTesting(isActive) + let isSelectedWorkspace = selectedWorkspaceId == tab.id + let isRetiringWorkspace = retiringWorkspaceId == tab.id + let isInputActive = isSelectedWorkspace || isRetiringWorkspace + let isVisible = isSelectedWorkspace || isRetiringWorkspace + WorkspaceContentView( + workspace: tab, + isWorkspaceVisible: isVisible, + isWorkspaceInputActive: isInputActive + ) + .opacity(isVisible ? 1 : 0) + .allowsHitTesting(isSelectedWorkspace) + .zIndex(isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0)) } } .opacity(sidebarSelectionState.selection == .tabs ? 1 : 0) @@ -590,6 +637,7 @@ struct ContentView: View { .onAppear { tabManager.applyWindowBackgroundForSelectedTab() reconcileMountedWorkspaceIds() + previousSelectedWorkspaceId = tabManager.selectedTabId if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId { selectedTabIds = [selectedId] lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } @@ -597,7 +645,18 @@ struct ContentView: View { updateTitlebarText() } .onChange(of: tabManager.selectedTabId) { newValue in +#if DEBUG + if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { + let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 + dlog( + "ws.view.selectedChange id=\(snapshot.id) dt=\(debugMsText(dtMs)) selected=\(debugShortWorkspaceId(newValue))" + ) + } else { + dlog("ws.view.selectedChange id=none selected=\(debugShortWorkspaceId(newValue))") + } +#endif tabManager.applyWindowBackgroundForSelectedTab() + startWorkspaceHandoffIfNeeded(newSelectedId: newValue) reconcileMountedWorkspaceIds(selectedId: newValue) guard let newValue else { return } if selectedTabIds.count <= 1 { @@ -606,6 +665,22 @@ struct ContentView: View { } updateTitlebarText() } + .onChange(of: tabManager.isWorkspaceCycleHot) { _ in +#if DEBUG + if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { + let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 + dlog( + "ws.view.hotChange id=\(snapshot.id) dt=\(debugMsText(dtMs)) hot=\(tabManager.isWorkspaceCycleHot ? 1 : 0)" + ) + } else { + dlog("ws.view.hotChange id=none hot=\(tabManager.isWorkspaceCycleHot ? 1 : 0)") + } +#endif + reconcileMountedWorkspaceIds() + } + .onChange(of: retiringWorkspaceId) { _ in + reconcileMountedWorkspaceIds() + } .onReceive(NotificationCenter.default.publisher(for: .ghosttyDidSetTitle)) { notification in guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID, tabId == tabManager.selectedTabId else { return } @@ -618,10 +693,24 @@ struct ContentView: View { .onReceive(NotificationCenter.default.publisher(for: .ghosttyDidFocusSurface)) { notification in guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID, tabId == tabManager.selectedTabId else { return } + completeWorkspaceHandoffIfNeeded(focusedTabId: tabId, reason: "focus") updateTitlebarText() } + .onReceive(NotificationCenter.default.publisher(for: .ghosttyDidBecomeFirstResponderSurface)) { notification in + guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID, + tabId == tabManager.selectedTabId else { return } + completeWorkspaceHandoffIfNeeded(focusedTabId: tabId, reason: "first_responder") + } .onReceive(tabManager.$tabs) { tabs in let existingIds = Set(tabs.map { $0.id }) + if let retiringWorkspaceId, !existingIds.contains(retiringWorkspaceId) { + self.retiringWorkspaceId = nil + workspaceHandoffFallbackTask?.cancel() + workspaceHandoffFallbackTask = nil + } + if let previousSelectedWorkspaceId, !existingIds.contains(previousSelectedWorkspaceId) { + self.previousSelectedWorkspaceId = tabManager.selectedTabId + } reconcileMountedWorkspaceIds(tabs: tabs) selectedTabIds = selectedTabIds.filter { existingIds.contains($0) } if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId { @@ -728,13 +817,44 @@ struct ContentView: View { private func reconcileMountedWorkspaceIds(tabs: [Workspace]? = nil, selectedId: UUID? = nil) { let currentTabs = tabs ?? tabManager.tabs - let existing = Set(currentTabs.map { $0.id }) + let orderedTabIds = currentTabs.map { $0.id } let effectiveSelectedId = selectedId ?? tabManager.selectedTabId + let pinnedIds = retiringWorkspaceId.map { Set([ $0 ]) } ?? [] + let isCycleHot = tabManager.isWorkspaceCycleHot + let baseMaxMounted = isCycleHot + ? WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle + : WorkspaceMountPolicy.maxMountedWorkspaces + let selectedCount = effectiveSelectedId == nil ? 0 : 1 + let maxMounted = max(baseMaxMounted, selectedCount + pinnedIds.count) + let previousMountedIds = mountedWorkspaceIds mountedWorkspaceIds = WorkspaceMountPolicy.nextMountedWorkspaceIds( current: mountedWorkspaceIds, selected: effectiveSelectedId, - existing: existing + pinnedIds: pinnedIds, + orderedTabIds: orderedTabIds, + isCycleHot: isCycleHot, + maxMounted: maxMounted ) +#if DEBUG + if mountedWorkspaceIds != previousMountedIds { + let added = mountedWorkspaceIds.filter { !previousMountedIds.contains($0) } + let removed = previousMountedIds.filter { !mountedWorkspaceIds.contains($0) } + if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { + let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 + dlog( + "ws.mount.reconcile id=\(snapshot.id) dt=\(debugMsText(dtMs)) hot=\(isCycleHot ? 1 : 0) " + + "selected=\(debugShortWorkspaceId(effectiveSelectedId)) " + + "mounted=\(debugShortWorkspaceIds(mountedWorkspaceIds)) " + + "added=\(debugShortWorkspaceIds(added)) removed=\(debugShortWorkspaceIds(removed))" + ) + } else { + dlog( + "ws.mount.reconcile id=none hot=\(isCycleHot ? 1 : 0) selected=\(debugShortWorkspaceId(effectiveSelectedId)) " + + "mounted=\(debugShortWorkspaceIds(mountedWorkspaceIds))" + ) + } + } +#endif } private func addTab() { @@ -758,6 +878,90 @@ struct ContentView: View { } } } + + private func startWorkspaceHandoffIfNeeded(newSelectedId: UUID?) { + let oldSelectedId = previousSelectedWorkspaceId + previousSelectedWorkspaceId = newSelectedId + + guard let oldSelectedId, let newSelectedId, oldSelectedId != newSelectedId else { + tabManager.completePendingWorkspaceUnfocus(reason: "no_handoff") + retiringWorkspaceId = nil + workspaceHandoffFallbackTask?.cancel() + workspaceHandoffFallbackTask = nil + return + } + + workspaceHandoffGeneration &+= 1 + let generation = workspaceHandoffGeneration + retiringWorkspaceId = oldSelectedId + workspaceHandoffFallbackTask?.cancel() + +#if DEBUG + if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { + let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 + dlog( + "ws.handoff.start id=\(snapshot.id) dt=\(debugMsText(dtMs)) old=\(debugShortWorkspaceId(oldSelectedId)) " + + "new=\(debugShortWorkspaceId(newSelectedId))" + ) + } else { + dlog( + "ws.handoff.start id=none old=\(debugShortWorkspaceId(oldSelectedId)) new=\(debugShortWorkspaceId(newSelectedId))" + ) + } +#endif + + workspaceHandoffFallbackTask = Task { [generation] in + do { + try await Task.sleep(nanoseconds: 150_000_000) + } catch { + return + } + await MainActor.run { + guard workspaceHandoffGeneration == generation else { return } + completeWorkspaceHandoff(reason: "timeout") + } + } + } + + private func completeWorkspaceHandoffIfNeeded(focusedTabId: UUID, reason: String) { + guard focusedTabId == tabManager.selectedTabId else { return } + guard retiringWorkspaceId != nil else { return } + completeWorkspaceHandoff(reason: reason) + } + + private func completeWorkspaceHandoff(reason: String) { + workspaceHandoffFallbackTask?.cancel() + workspaceHandoffFallbackTask = nil + let retiring = retiringWorkspaceId + retiringWorkspaceId = nil + tabManager.completePendingWorkspaceUnfocus(reason: reason) +#if DEBUG + if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { + let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 + dlog( + "ws.handoff.complete id=\(snapshot.id) dt=\(debugMsText(dtMs)) reason=\(reason) retiring=\(debugShortWorkspaceId(retiring))" + ) + } else { + dlog("ws.handoff.complete id=none reason=\(reason) retiring=\(debugShortWorkspaceId(retiring))") + } +#endif + } + +#if DEBUG + private func debugShortWorkspaceId(_ id: UUID?) -> String { + guard let id else { return "nil" } + return String(id.uuidString.prefix(5)) + } + + private func debugShortWorkspaceIds(_ ids: [UUID]) -> String { + if ids.isEmpty { return "[]" } + return "[" + ids.map { String($0.uuidString.prefix(5)) }.joined(separator: ",") + "]" + } + + private func debugMsText(_ ms: Double) -> String { + String(format: "%.2fms", ms) + } +#endif } struct VerticalTabsSidebar: View { diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index a0d4bc88..68572571 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1842,6 +1842,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { ) } #endif + if let terminalSurface { + NotificationCenter.default.post( + name: .ghosttyDidBecomeFirstResponderSurface, + object: nil, + userInfo: [ + GhosttyNotificationKey.tabId: terminalSurface.tabId, + GhosttyNotificationKey.surfaceId: terminalSurface.id, + ] + ) + } ghostty_surface_set_focus(surface, true) // Ghostty only restarts its vsync display link on display-id changes while focused. @@ -2734,6 +2744,10 @@ final class GhosttySurfaceScrollView: NSView { } return (presentCounts[surfaceId, default: 0], lastPresentTimes[surfaceId, default: 0], key) } + + var debugSurfaceId: UUID? { + surfaceView.terminalSurface?.id + } #endif init(surfaceView: GhosttyNSView) { @@ -2969,8 +2983,17 @@ final class GhosttySurfaceScrollView: NSView { } func setVisibleInUI(_ visible: Bool) { + let wasVisible = surfaceView.isVisibleInUI surfaceView.setVisibleInUI(visible) isHidden = !visible +#if DEBUG + if wasVisible != visible { + debugLogWorkspaceSwitchTiming( + event: "ws.term.visible", + suffix: "surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") value=\(visible ? 1 : 0)" + ) + } +#endif if !visible { // If we were focused, yield first responder. if let window, let fr = window.firstResponder as? NSView, @@ -2983,7 +3006,16 @@ final class GhosttySurfaceScrollView: NSView { } func setActive(_ active: Bool) { + let wasActive = isActive isActive = active +#if DEBUG + if wasActive != active { + debugLogWorkspaceSwitchTiming( + event: "ws.term.active", + suffix: "surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") value=\(active ? 1 : 0)" + ) + } +#endif if active { applyFirstResponderIfNeeded() } else if let window, @@ -2993,6 +3025,17 @@ final class GhosttySurfaceScrollView: NSView { } } +#if DEBUG + private func debugLogWorkspaceSwitchTiming(event: String, suffix: String) { + guard let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() else { + dlog("\(event) id=none \(suffix)") + return + } + let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 + dlog("\(event) id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) \(suffix)") + } +#endif + func moveFocus(from previous: GhosttySurfaceScrollView? = nil, delay: TimeInterval? = nil) { #if DEBUG dlog("focus.moveFocus to=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil")") @@ -3686,9 +3729,27 @@ struct GhosttyTerminalView: NSViewRepresentable { func updateNSView(_ nsView: NSView, context: Context) { let hostedView = terminalSurface.hostedView let coordinator = context.coordinator + let previousDesiredIsActive = coordinator.desiredIsActive + let previousDesiredIsVisibleInUI = coordinator.desiredIsVisibleInUI coordinator.desiredIsActive = isActive coordinator.desiredIsVisibleInUI = isVisibleInUI coordinator.hostedView = hostedView +#if DEBUG + if previousDesiredIsActive != isActive || previousDesiredIsVisibleInUI != isVisibleInUI { + if let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() { + let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 + dlog( + "ws.swiftui.update id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) " + + "surface=\(terminalSurface.id.uuidString.prefix(5)) visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0)" + ) + } else { + dlog( + "ws.swiftui.update id=none surface=\(terminalSurface.id.uuidString.prefix(5)) " + + "visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0)" + ) + } + } +#endif // Keep the surface lifecycle and handlers updated even if we defer re-parenting. hostedView.attachSurface(terminalSurface) @@ -3744,6 +3805,19 @@ struct GhosttyTerminalView: NSViewRepresentable { coordinator.attachGeneration += 1 coordinator.desiredIsActive = false coordinator.desiredIsVisibleInUI = false +#if DEBUG + if let hostedView = coordinator.hostedView { + if let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() { + let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 + dlog( + "ws.swiftui.dismantle id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) " + + "surface=\(hostedView.debugSurfaceId?.uuidString.prefix(5) ?? "nil")" + ) + } else { + dlog("ws.swiftui.dismantle id=none surface=\(hostedView.debugSurfaceId?.uuidString.prefix(5) ?? "nil")") + } + } +#endif if let host = nsView as? HostContainerView { host.onDidMoveToWindow = nil diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index ea7ee068..e33fb425 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -221,6 +221,7 @@ fileprivate func cmuxVsyncIOSurfaceTimelineCallback( @MainActor class TabManager: ObservableObject { @Published var tabs: [Workspace] = [] + @Published private(set) var isWorkspaceCycleHot: Bool = false @Published var selectedTabId: UUID? { didSet { guard selectedTabId != oldValue else { return } @@ -232,12 +233,34 @@ class TabManager: ObservableObject { if !isNavigatingHistory, let selectedTabId { recordTabInHistory(selectedTabId) } +#if DEBUG + let switchId = debugWorkspaceSwitchId + let switchDtMs = debugWorkspaceSwitchStartTime > 0 + ? (CACurrentMediaTime() - debugWorkspaceSwitchStartTime) * 1000 + : 0 + dlog( + "ws.select.didSet id=\(switchId) from=\(Self.debugShortWorkspaceId(previousTabId)) " + + "to=\(Self.debugShortWorkspaceId(selectedTabId)) dt=\(Self.debugMsText(switchDtMs))" + ) +#endif + selectionSideEffectsGeneration &+= 1 + let generation = selectionSideEffectsGeneration DispatchQueue.main.async { [weak self] in - self?.focusSelectedTabPanel(previousTabId: previousTabId) - self?.updateWindowTitleForSelectedTab() - if let selectedTabId = self?.selectedTabId { - self?.markFocusedPanelReadIfActive(tabId: selectedTabId) + guard let self, self.selectionSideEffectsGeneration == generation else { return } + self.focusSelectedTabPanel(previousTabId: previousTabId) + self.updateWindowTitleForSelectedTab() + if let selectedTabId = self.selectedTabId { + self.markFocusedPanelReadIfActive(tabId: selectedTabId) } +#if DEBUG + let dtMs = self.debugWorkspaceSwitchStartTime > 0 + ? (CACurrentMediaTime() - self.debugWorkspaceSwitchStartTime) * 1000 + : 0 + dlog( + "ws.select.asyncDone id=\(self.debugWorkspaceSwitchId) dt=\(Self.debugMsText(dtMs)) " + + "selected=\(Self.debugShortWorkspaceId(self.selectedTabId))" + ) +#endif } } } @@ -250,6 +273,15 @@ class TabManager: ObservableObject { private var historyIndex: Int = -1 private var isNavigatingHistory = false private let maxHistorySize = 50 + private var selectionSideEffectsGeneration: UInt64 = 0 + private var workspaceCycleGeneration: UInt64 = 0 + private var workspaceCycleCooldownTask: Task? + private var pendingWorkspaceUnfocusTarget: (tabId: UUID, panelId: UUID)? +#if DEBUG + private var debugWorkspaceSwitchCounter: UInt64 = 0 + private var debugWorkspaceSwitchId: UInt64 = 0 + private var debugWorkspaceSwitchStartTime: CFTimeInterval = 0 +#endif #if DEBUG private var didSetupSplitCloseRightUITest = false @@ -291,6 +323,10 @@ class TabManager: ObservableObject { #endif } + deinit { + workspaceCycleCooldownTask?.cancel() + } + var selectedWorkspace: Workspace? { guard let selectedTabId else { return nil } return tabs.first(where: { $0.id == selectedTabId }) @@ -814,12 +850,15 @@ class TabManager: ObservableObject { guard let panelId = tab.focusedPanelId, let panel = tab.panels[panelId] else { return } - // Unfocus previous tab's panel + // Defer unfocusing the previous workspace's panel until ContentView confirms handoff + // completion (new workspace has focus or timeout fallback), to avoid a visible freeze gap. if let previousTabId, let previousTab = tabs.first(where: { $0.id == previousTabId }), let previousPanelId = previousTab.focusedPanelId, - let previousPanel = previousTab.panels[previousPanelId] { - previousPanel.unfocus() + previousTab.panels[previousPanelId] != nil { + replacePendingWorkspaceUnfocusTarget( + with: (tabId: previousTabId, panelId: previousPanelId) + ) } panel.focus() @@ -830,6 +869,94 @@ class TabManager: ObservableObject { } } + func completePendingWorkspaceUnfocus(reason: String) { + guard let pending = pendingWorkspaceUnfocusTarget else { return } + // If this tab became selected again before handoff completion, drop the stale + // pending entry so it cannot be flushed later and deactivate the selected workspace. + guard Self.shouldUnfocusPendingWorkspace( + pendingTabId: pending.tabId, + selectedTabId: selectedTabId + ) else { + pendingWorkspaceUnfocusTarget = nil +#if DEBUG + dlog( + "ws.unfocus.drop tab=\(Self.debugShortWorkspaceId(pending.tabId)) panel=\(String(pending.panelId.uuidString.prefix(5))) reason=selected_again" + ) +#endif + return + } + pendingWorkspaceUnfocusTarget = nil + unfocusWorkspacePanel(tabId: pending.tabId, panelId: pending.panelId) +#if DEBUG + if let snapshot = debugCurrentWorkspaceSwitchSnapshot() { + let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 + dlog( + "ws.unfocus.complete id=\(snapshot.id) dt=\(Self.debugMsText(dtMs)) " + + "tab=\(Self.debugShortWorkspaceId(pending.tabId)) panel=\(String(pending.panelId.uuidString.prefix(5))) reason=\(reason)" + ) + } else { + dlog( + "ws.unfocus.complete id=none tab=\(Self.debugShortWorkspaceId(pending.tabId)) " + + "panel=\(String(pending.panelId.uuidString.prefix(5))) reason=\(reason)" + ) + } +#endif + } + + private func replacePendingWorkspaceUnfocusTarget(with next: (tabId: UUID, panelId: UUID)) { + if let current = pendingWorkspaceUnfocusTarget, + current.tabId == next.tabId, + current.panelId == next.panelId { + return + } + + if let current = pendingWorkspaceUnfocusTarget { + // Never unfocus the currently selected workspace when replacing stale pending state. + if Self.shouldUnfocusPendingWorkspace( + pendingTabId: current.tabId, + selectedTabId: selectedTabId + ) { + unfocusWorkspacePanel(tabId: current.tabId, panelId: current.panelId) +#if DEBUG + dlog( + "ws.unfocus.flush tab=\(Self.debugShortWorkspaceId(current.tabId)) panel=\(String(current.panelId.uuidString.prefix(5))) reason=replaced" + ) +#endif + } else { +#if DEBUG + dlog( + "ws.unfocus.drop tab=\(Self.debugShortWorkspaceId(current.tabId)) panel=\(String(current.panelId.uuidString.prefix(5))) reason=replaced_selected" + ) +#endif + } + } + + pendingWorkspaceUnfocusTarget = next +#if DEBUG + if let snapshot = debugCurrentWorkspaceSwitchSnapshot() { + let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 + dlog( + "ws.unfocus.defer id=\(snapshot.id) dt=\(Self.debugMsText(dtMs)) " + + "tab=\(Self.debugShortWorkspaceId(next.tabId)) panel=\(String(next.panelId.uuidString.prefix(5)))" + ) + } else { + dlog( + "ws.unfocus.defer id=none tab=\(Self.debugShortWorkspaceId(next.tabId)) panel=\(String(next.panelId.uuidString.prefix(5)))" + ) + } +#endif + } + + private func unfocusWorkspacePanel(tabId: UUID, panelId: UUID) { + guard let tab = tabs.first(where: { $0.id == tabId }), + let panel = tab.panels[panelId] else { return } + panel.unfocus() + } + + static func shouldUnfocusPendingWorkspace(pendingTabId: UUID, selectedTabId: UUID?) -> Bool { + selectedTabId != pendingTabId + } + private func markFocusedPanelReadIfActive(tabId: UUID) { let shouldSuppressFlash = suppressFocusFlash suppressFocusFlash = false @@ -959,6 +1086,17 @@ class TabManager: ObservableObject { guard let currentId = selectedTabId, let currentIndex = tabs.firstIndex(where: { $0.id == currentId }) else { return } let nextIndex = (currentIndex + 1) % tabs.count +#if DEBUG + let nextId = tabs[nextIndex].id + debugWorkspaceSwitchCounter &+= 1 + debugWorkspaceSwitchId = debugWorkspaceSwitchCounter + debugWorkspaceSwitchStartTime = CACurrentMediaTime() + dlog( + "ws.switch.begin id=\(debugWorkspaceSwitchId) dir=next from=\(Self.debugShortWorkspaceId(currentId)) " + + "to=\(Self.debugShortWorkspaceId(nextId)) hot=\(isWorkspaceCycleHot ? 1 : 0) tabs=\(tabs.count)" + ) +#endif + activateWorkspaceCycleHotWindow() selectedTabId = tabs[nextIndex].id } @@ -966,9 +1104,97 @@ class TabManager: ObservableObject { guard let currentId = selectedTabId, let currentIndex = tabs.firstIndex(where: { $0.id == currentId }) else { return } let prevIndex = (currentIndex - 1 + tabs.count) % tabs.count +#if DEBUG + let prevId = tabs[prevIndex].id + debugWorkspaceSwitchCounter &+= 1 + debugWorkspaceSwitchId = debugWorkspaceSwitchCounter + debugWorkspaceSwitchStartTime = CACurrentMediaTime() + dlog( + "ws.switch.begin id=\(debugWorkspaceSwitchId) dir=prev from=\(Self.debugShortWorkspaceId(currentId)) " + + "to=\(Self.debugShortWorkspaceId(prevId)) hot=\(isWorkspaceCycleHot ? 1 : 0) tabs=\(tabs.count)" + ) +#endif + activateWorkspaceCycleHotWindow() selectedTabId = tabs[prevIndex].id } + private func activateWorkspaceCycleHotWindow() { + workspaceCycleGeneration &+= 1 + let generation = workspaceCycleGeneration +#if DEBUG + let switchId = debugWorkspaceSwitchId + let switchDtMs = debugWorkspaceSwitchStartTime > 0 + ? (CACurrentMediaTime() - debugWorkspaceSwitchStartTime) * 1000 + : 0 +#endif + if !isWorkspaceCycleHot { + isWorkspaceCycleHot = true +#if DEBUG + dlog( + "ws.hot.on id=\(switchId) gen=\(generation) dt=\(Self.debugMsText(switchDtMs))" + ) +#endif + } + + let hadPendingCooldown = workspaceCycleCooldownTask != nil + workspaceCycleCooldownTask?.cancel() +#if DEBUG + if hadPendingCooldown { + dlog( + "ws.hot.cancelPrev id=\(switchId) gen=\(generation) dt=\(Self.debugMsText(switchDtMs))" + ) + } +#endif + workspaceCycleCooldownTask = Task { [weak self, generation] in + do { + try await Task.sleep(nanoseconds: 220_000_000) + } catch { +#if DEBUG + await MainActor.run { + guard let self else { return } + let dtMs = self.debugWorkspaceSwitchStartTime > 0 + ? (CACurrentMediaTime() - self.debugWorkspaceSwitchStartTime) * 1000 + : 0 + dlog( + "ws.hot.cooldownCanceled id=\(self.debugWorkspaceSwitchId) gen=\(generation) dt=\(Self.debugMsText(dtMs))" + ) + } +#endif + return + } + await MainActor.run { + guard let self else { return } + guard self.workspaceCycleGeneration == generation else { return } +#if DEBUG + let dtMs = self.debugWorkspaceSwitchStartTime > 0 + ? (CACurrentMediaTime() - self.debugWorkspaceSwitchStartTime) * 1000 + : 0 + dlog( + "ws.hot.off id=\(self.debugWorkspaceSwitchId) gen=\(generation) dt=\(Self.debugMsText(dtMs))" + ) +#endif + self.isWorkspaceCycleHot = false + self.workspaceCycleCooldownTask = nil + } + } + } + +#if DEBUG + func debugCurrentWorkspaceSwitchSnapshot() -> (id: UInt64, startedAt: CFTimeInterval)? { + guard debugWorkspaceSwitchId > 0, debugWorkspaceSwitchStartTime > 0 else { return nil } + return (debugWorkspaceSwitchId, debugWorkspaceSwitchStartTime) + } + + private static func debugShortWorkspaceId(_ id: UUID?) -> String { + guard let id else { return "nil" } + return String(id.uuidString.prefix(5)) + } + + private static func debugMsText(_ ms: Double) -> String { + String(format: "%.2fms", ms) + } +#endif + func selectTab(at index: Int) { guard index >= 0 && index < tabs.count else { return } selectedTabId = tabs[index].id @@ -2222,6 +2448,7 @@ extension Notification.Name { static let ghosttyDidSetTitle = Notification.Name("ghosttyDidSetTitle") static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab") static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface") + static let ghosttyDidBecomeFirstResponderSurface = Notification.Name("ghosttyDidBecomeFirstResponderSurface") static let browserFocusAddressBar = Notification.Name("browserFocusAddressBar") static let browserMoveOmnibarSelection = Notification.Name("browserMoveOmnibarSelection") static let browserDidExitAddressBar = Notification.Name("browserDidExitAddressBar") diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index bd2f6ccf..92fc0a42 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -5,7 +5,8 @@ import Bonsplit /// View that renders a Workspace's content using BonsplitView struct WorkspaceContentView: View { @ObservedObject var workspace: Workspace - let isTabActive: Bool + let isWorkspaceVisible: Bool + let isWorkspaceInputActive: Bool @State private var config = GhosttyConfig.load() @EnvironmentObject var notificationStore: TerminalNotificationStore @@ -16,7 +17,7 @@ struct WorkspaceContentView: View { // Inactive workspaces are kept alive in a ZStack (for state preservation) but their // AppKit-backed views can still intercept drags. Disable drop acceptance for them. - let _ = { workspace.bonsplitController.isInteractive = isTabActive }() + let _ = { workspace.bonsplitController.isInteractive = isWorkspaceInputActive }() // Wire up file drop handling so bonsplit's PaneDragContainerView can forward // Finder file drops to the correct terminal panel. @@ -35,9 +36,9 @@ struct WorkspaceContentView: View { // Content for each tab in bonsplit let _ = Self.debugPanelLookup(tab: tab, workspace: workspace) if let panel = workspace.panel(for: tab.id) { - let isFocused = isTabActive && workspace.focusedPanelId == panel.id + let isFocused = isWorkspaceInputActive && workspace.focusedPanelId == panel.id let isSelectedInPane = workspace.bonsplitController.selectedTab(inPane: paneId)?.id == tab.id - let isVisibleInUI = isTabActive && isSelectedInPane + let isVisibleInUI = isWorkspaceVisible && isSelectedInPane PanelContentView( panel: panel, isFocused: isFocused, @@ -50,12 +51,12 @@ struct WorkspaceContentView: View { // Keep bonsplit focus in sync with the AppKit first responder for the // active workspace. This prevents divergence between the blue focused-tab // indicator and where keyboard input/flash-focus actually lands. - guard isTabActive else { return } + guard isWorkspaceInputActive else { return } guard workspace.panels[panel.id] != nil else { return } workspace.focusPanel(panel.id) }, onRequestPanelFocus: { - guard isTabActive else { return } + guard isWorkspaceInputActive else { return } guard workspace.panels[panel.id] != nil else { return } workspace.focusPanel(panel.id) }, diff --git a/scripts/test-unit.sh b/scripts/test-unit.sh new file mode 100755 index 00000000..a35200fc --- /dev/null +++ b/scripts/test-unit.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +PROJECT="GhosttyTabs.xcodeproj" +SCHEME="cmux-unit" +CONFIGURATION="${CMUX_TEST_CONFIGURATION:-Debug}" +DESTINATION="${CMUX_TEST_DESTINATION:-platform=macOS}" + +# Default to `test` when no explicit xcodebuild action is provided. +if [ "$#" -eq 0 ]; then + set -- test +fi + +exec xcodebuild \ + -project "$PROJECT" \ + -scheme "$SCHEME" \ + -configuration "$CONFIGURATION" \ + -destination "$DESTINATION" \ + "$@" From 442eb1f01d176e9117f59c2a3682ca760960781e Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:19:56 -0800 Subject: [PATCH 8/9] Rename test targets to cmuxTests and cmuxUITests --- .github/workflows/ci.yml | 2 +- CLAUDE.md | 2 +- CONTRIBUTING.md | 2 +- GhosttyTabs.xcodeproj/project.pbxproj | 48 +++++++++---------- .../xcshareddata/xcschemes/cmux-unit.xcscheme | 4 +- .../xcshareddata/xcschemes/cmux.xcscheme | 2 +- .../CmuxWebViewKeyEquivalentTests.swift | 0 .../UpdatePillReleaseVisibilityTests.swift | 0 .../AutomationSocketUITests.swift | 0 .../BrowserOmnibarSuggestionsUITests.swift | 0 .../BrowserPaneNavigationKeybindUITests.swift | 0 .../CloseWorkspaceCmdDUITests.swift | 0 .../CloseWorkspaceConfirmDialogUITests.swift | 0 .../JumpToUnreadUITests.swift | 0 .../MenuKeyEquivalentRoutingUITests.swift | 0 .../MultiWindowNotificationsUITests.swift | 0 .../SidebarResizeUITests.swift | 0 .../UpdatePillUITests.swift | 0 18 files changed, 30 insertions(+), 30 deletions(-) rename {GhosttyTabsTests => cmuxTests}/CmuxWebViewKeyEquivalentTests.swift (100%) rename {GhosttyTabsTests => cmuxTests}/UpdatePillReleaseVisibilityTests.swift (100%) rename {GhosttyTabsUITests => cmuxUITests}/AutomationSocketUITests.swift (100%) rename {GhosttyTabsUITests => cmuxUITests}/BrowserOmnibarSuggestionsUITests.swift (100%) rename {GhosttyTabsUITests => cmuxUITests}/BrowserPaneNavigationKeybindUITests.swift (100%) rename {GhosttyTabsUITests => cmuxUITests}/CloseWorkspaceCmdDUITests.swift (100%) rename {GhosttyTabsUITests => cmuxUITests}/CloseWorkspaceConfirmDialogUITests.swift (100%) rename {GhosttyTabsUITests => cmuxUITests}/JumpToUnreadUITests.swift (100%) rename {GhosttyTabsUITests => cmuxUITests}/MenuKeyEquivalentRoutingUITests.swift (100%) rename {GhosttyTabsUITests => cmuxUITests}/MultiWindowNotificationsUITests.swift (100%) rename {GhosttyTabsUITests => cmuxUITests}/SidebarResizeUITests.swift (100%) rename {GhosttyTabsUITests => cmuxUITests}/UpdatePillUITests.swift (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa59e76f..ad2701c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,4 +80,4 @@ jobs: run: | set -euo pipefail # Run directly on the self-hosted macOS runner. - xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" -only-testing:GhosttyTabsUITests/UpdatePillUITests test + xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" -only-testing:cmuxUITests/UpdatePillUITests test diff --git a/CLAUDE.md b/CLAUDE.md index 2d989dd6..36094c6b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,7 +96,7 @@ tail -f /tmp/cmux-debug.log Run UI tests on the UTM macOS VM (never on the host machine). Always run e2e UI tests via `ssh cmux-vm`: ```bash -ssh cmux-vm 'cd /Users/cmux/GhosttyTabs && xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" -only-testing:GhosttyTabsUITests/UpdatePillUITests test' +ssh cmux-vm 'cd /Users/cmux/GhosttyTabs && xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" -only-testing:cmuxUITests/UpdatePillUITests test' ``` ## Basic tests diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b5526dae..eba5d6bf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,7 +59,7 @@ ssh cmux-vm 'cd /Users/cmux/GhosttyTabs && xcodebuild -project GhosttyTabs.xcode ### UI tests (run on VM) ```bash -ssh cmux-vm 'cd /Users/cmux/GhosttyTabs && xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" -only-testing:GhosttyTabsUITests test' +ssh cmux-vm 'cd /Users/cmux/GhosttyTabs && xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" -only-testing:cmuxUITests test' ``` ## Ghostty Submodule diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index bf2856d2..8f8ba7f9 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -124,8 +124,8 @@ /* Begin PBXFileReference section */ A5001000 /* cmux.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = cmux.app; sourceTree = BUILT_PRODUCTS_DIR; }; - F1000002A1B2C3D4E5F60718 /* GhosttyTabsTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GhosttyTabsTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 7E7E6EF344A568AC7FEE3715 /* GhosttyTabsUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GhosttyTabsUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + F1000002A1B2C3D4E5F60718 /* cmuxTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = cmuxTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 7E7E6EF344A568AC7FEE3715 /* cmuxUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = cmuxUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; A5001011 /* cmuxApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmuxApp.swift; sourceTree = ""; }; A5001012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSelectionState.swift; sourceTree = ""; }; @@ -284,8 +284,8 @@ A5001016 /* GhosttyKit.xcframework */, A5001017 /* ghostty.h */, A5001018 /* cmux-Bridging-Header.h */, - 3196C9C2D01F054C1D3385DD /* GhosttyTabsUITests */, - F1000003A1B2C3D4E5F60718 /* GhosttyTabsTests */, + 3196C9C2D01F054C1D3385DD /* cmuxUITests */, + F1000003A1B2C3D4E5F60718 /* cmuxTests */, A5001042 /* Products */, ); sourceTree = ""; @@ -362,13 +362,13 @@ children = ( A5001000 /* cmux.app */, B9000004A1B2C3D4E5F60719 /* cmux */, - 7E7E6EF344A568AC7FEE3715 /* GhosttyTabsUITests.xctest */, - F1000002A1B2C3D4E5F60718 /* GhosttyTabsTests.xctest */, + 7E7E6EF344A568AC7FEE3715 /* cmuxUITests.xctest */, + F1000002A1B2C3D4E5F60718 /* cmuxTests.xctest */, ); name = Products; sourceTree = ""; }; - 3196C9C2D01F054C1D3385DD /* GhosttyTabsUITests */ = { + 3196C9C2D01F054C1D3385DD /* cmuxUITests */ = { isa = PBXGroup; children = ( B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */, @@ -382,16 +382,16 @@ C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */, E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */, ); - path = GhosttyTabsUITests; + path = cmuxUITests; sourceTree = ""; }; - F1000003A1B2C3D4E5F60718 /* GhosttyTabsTests */ = { + F1000003A1B2C3D4E5F60718 /* cmuxTests */ = { isa = PBXGroup; children = ( F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */, F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */, ); - path = GhosttyTabsTests; + path = cmuxTests; sourceTree = ""; }; /* End PBXGroup section */ @@ -440,9 +440,9 @@ productReference = B9000004A1B2C3D4E5F60719 /* cmux */; productType = "com.apple.product-type.tool"; }; - CB450DF0F0B3839599082C4D /* GhosttyTabsUITests */ = { + CB450DF0F0B3839599082C4D /* cmuxUITests */ = { isa = PBXNativeTarget; - buildConfigurationList = AD2C7ED08993D3CD4910A1FF /* Build configuration list for PBXNativeTarget "GhosttyTabsUITests" */; + buildConfigurationList = AD2C7ED08993D3CD4910A1FF /* Build configuration list for PBXNativeTarget "cmuxUITests" */; buildPhases = ( E436EF0BA8EC9E6721A42F79 /* Sources */, AB408954939A11B8A87BB5DE /* Frameworks */, @@ -453,14 +453,14 @@ dependencies = ( 32568B0DCFC8656BA952468E /* PBXTargetDependency */, ); - name = GhosttyTabsUITests; - productName = GhosttyTabsUITests; - productReference = 7E7E6EF344A568AC7FEE3715 /* GhosttyTabsUITests.xctest */; + name = cmuxUITests; + productName = cmuxUITests; + productReference = 7E7E6EF344A568AC7FEE3715 /* cmuxUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; - F1000004A1B2C3D4E5F60718 /* GhosttyTabsTests */ = { + F1000004A1B2C3D4E5F60718 /* cmuxTests */ = { isa = PBXNativeTarget; - buildConfigurationList = F1000010A1B2C3D4E5F60718 /* Build configuration list for PBXNativeTarget "GhosttyTabsTests" */; + buildConfigurationList = F1000010A1B2C3D4E5F60718 /* Build configuration list for PBXNativeTarget "cmuxTests" */; buildPhases = ( F1000005A1B2C3D4E5F60718 /* Sources */, F1000006A1B2C3D4E5F60718 /* Frameworks */, @@ -471,9 +471,9 @@ dependencies = ( F1000009A1B2C3D4E5F60718 /* PBXTargetDependency */, ); - name = GhosttyTabsTests; - productName = GhosttyTabsTests; - productReference = F1000002A1B2C3D4E5F60718 /* GhosttyTabsTests.xctest */; + name = cmuxTests; + productName = cmuxTests; + productReference = F1000002A1B2C3D4E5F60718 /* cmuxTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; /* End PBXNativeTarget section */ @@ -507,8 +507,8 @@ targets = ( A5001050 /* GhosttyTabs */, B9000005A1B2C3D4E5F60719 /* cmux-cli */, - CB450DF0F0B3839599082C4D /* GhosttyTabsUITests */, - F1000004A1B2C3D4E5F60718 /* GhosttyTabsTests */, + CB450DF0F0B3839599082C4D /* cmuxUITests */, + F1000004A1B2C3D4E5F60718 /* cmuxTests */, ); }; /* End PBXProject section */ @@ -920,7 +920,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - AD2C7ED08993D3CD4910A1FF /* Build configuration list for PBXNativeTarget "GhosttyTabsUITests" */ = { + AD2C7ED08993D3CD4910A1FF /* Build configuration list for PBXNativeTarget "cmuxUITests" */ = { isa = XCConfigurationList; buildConfigurations = ( C117776A77E71D1432F570D7 /* Debug */, @@ -929,7 +929,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - F1000010A1B2C3D4E5F60718 /* Build configuration list for PBXNativeTarget "GhosttyTabsTests" */ = { + F1000010A1B2C3D4E5F60718 /* Build configuration list for PBXNativeTarget "cmuxTests" */ = { isa = XCConfigurationList; buildConfigurations = ( F1000011A1B2C3D4E5F60718 /* Debug */, diff --git a/GhosttyTabs.xcodeproj/xcshareddata/xcschemes/cmux-unit.xcscheme b/GhosttyTabs.xcodeproj/xcshareddata/xcschemes/cmux-unit.xcscheme index 5f05961e..965b79c1 100644 --- a/GhosttyTabs.xcodeproj/xcshareddata/xcschemes/cmux-unit.xcscheme +++ b/GhosttyTabs.xcodeproj/xcshareddata/xcschemes/cmux-unit.xcscheme @@ -6,14 +6,14 @@ - + - + diff --git a/GhosttyTabs.xcodeproj/xcshareddata/xcschemes/cmux.xcscheme b/GhosttyTabs.xcodeproj/xcshareddata/xcschemes/cmux.xcscheme index f326bfd8..23f45429 100644 --- a/GhosttyTabs.xcodeproj/xcshareddata/xcschemes/cmux.xcscheme +++ b/GhosttyTabs.xcodeproj/xcshareddata/xcschemes/cmux.xcscheme @@ -10,7 +10,7 @@ - + diff --git a/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift similarity index 100% rename from GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift rename to cmuxTests/CmuxWebViewKeyEquivalentTests.swift diff --git a/GhosttyTabsTests/UpdatePillReleaseVisibilityTests.swift b/cmuxTests/UpdatePillReleaseVisibilityTests.swift similarity index 100% rename from GhosttyTabsTests/UpdatePillReleaseVisibilityTests.swift rename to cmuxTests/UpdatePillReleaseVisibilityTests.swift diff --git a/GhosttyTabsUITests/AutomationSocketUITests.swift b/cmuxUITests/AutomationSocketUITests.swift similarity index 100% rename from GhosttyTabsUITests/AutomationSocketUITests.swift rename to cmuxUITests/AutomationSocketUITests.swift diff --git a/GhosttyTabsUITests/BrowserOmnibarSuggestionsUITests.swift b/cmuxUITests/BrowserOmnibarSuggestionsUITests.swift similarity index 100% rename from GhosttyTabsUITests/BrowserOmnibarSuggestionsUITests.swift rename to cmuxUITests/BrowserOmnibarSuggestionsUITests.swift diff --git a/GhosttyTabsUITests/BrowserPaneNavigationKeybindUITests.swift b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift similarity index 100% rename from GhosttyTabsUITests/BrowserPaneNavigationKeybindUITests.swift rename to cmuxUITests/BrowserPaneNavigationKeybindUITests.swift diff --git a/GhosttyTabsUITests/CloseWorkspaceCmdDUITests.swift b/cmuxUITests/CloseWorkspaceCmdDUITests.swift similarity index 100% rename from GhosttyTabsUITests/CloseWorkspaceCmdDUITests.swift rename to cmuxUITests/CloseWorkspaceCmdDUITests.swift diff --git a/GhosttyTabsUITests/CloseWorkspaceConfirmDialogUITests.swift b/cmuxUITests/CloseWorkspaceConfirmDialogUITests.swift similarity index 100% rename from GhosttyTabsUITests/CloseWorkspaceConfirmDialogUITests.swift rename to cmuxUITests/CloseWorkspaceConfirmDialogUITests.swift diff --git a/GhosttyTabsUITests/JumpToUnreadUITests.swift b/cmuxUITests/JumpToUnreadUITests.swift similarity index 100% rename from GhosttyTabsUITests/JumpToUnreadUITests.swift rename to cmuxUITests/JumpToUnreadUITests.swift diff --git a/GhosttyTabsUITests/MenuKeyEquivalentRoutingUITests.swift b/cmuxUITests/MenuKeyEquivalentRoutingUITests.swift similarity index 100% rename from GhosttyTabsUITests/MenuKeyEquivalentRoutingUITests.swift rename to cmuxUITests/MenuKeyEquivalentRoutingUITests.swift diff --git a/GhosttyTabsUITests/MultiWindowNotificationsUITests.swift b/cmuxUITests/MultiWindowNotificationsUITests.swift similarity index 100% rename from GhosttyTabsUITests/MultiWindowNotificationsUITests.swift rename to cmuxUITests/MultiWindowNotificationsUITests.swift diff --git a/GhosttyTabsUITests/SidebarResizeUITests.swift b/cmuxUITests/SidebarResizeUITests.swift similarity index 100% rename from GhosttyTabsUITests/SidebarResizeUITests.swift rename to cmuxUITests/SidebarResizeUITests.swift diff --git a/GhosttyTabsUITests/UpdatePillUITests.swift b/cmuxUITests/UpdatePillUITests.swift similarity index 100% rename from GhosttyTabsUITests/UpdatePillUITests.swift rename to cmuxUITests/UpdatePillUITests.swift From ee4848c0088ee50d5eb835d5a557d9a7fdaa18fd Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:13:40 -0800 Subject: [PATCH 9/9] Speed up workspace switching: reduce portal churn and enforce selected z-order --- Sources/ContentView.swift | 59 +++++--- Sources/GhosttyTerminalView.swift | 52 ++++--- Sources/Panels/PanelContentView.swift | 2 + Sources/Panels/TerminalPanelView.swift | 2 + Sources/TerminalWindowPortal.swift | 36 ++++- Sources/WorkspaceContentView.swift | 2 + cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 133 +++++++++++++++++- 7 files changed, 240 insertions(+), 46 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index f2d7bf6b..7fc4f75b 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -274,7 +274,8 @@ var fileDropOverlayKey: UInt8 = 0 enum WorkspaceMountPolicy { // Keep only the selected workspace mounted to minimize layer-tree traversal. static let maxMountedWorkspaces = 1 - static let maxMountedWorkspacesDuringCycle = 3 + // During workspace cycling, keep only a minimal handoff pair (selected + retiring). + static let maxMountedWorkspacesDuringCycle = 2 static func nextMountedWorkspaceIds( current: [UUID], @@ -293,12 +294,6 @@ enum WorkspaceMountPolicy { ordered.insert(selected, at: 0) } - let prioritizedPinnedIds = pinnedIds.filter { existing.contains($0) && $0 != selected } - for pinnedId in prioritizedPinnedIds.reversed() { - ordered.removeAll { $0 == pinnedId } - ordered.insert(pinnedId, at: 0) - } - if isCycleHot, let selected { let warmIds = cycleWarmIds(selected: selected, orderedTabIds: orderedTabIds) for id in warmIds.reversed() { @@ -307,6 +302,33 @@ enum WorkspaceMountPolicy { } } + if isCycleHot, + pinnedIds.isEmpty, + let selected { + ordered.removeAll { $0 != selected } + } + + // Ensure pinned ids (retiring handoff workspaces) are always retained at highest priority. + // This runs after warming to prevent neighbor warming from evicting the retiring workspace. + let prioritizedPinnedIds = pinnedIds + .filter { existing.contains($0) && $0 != selected } + .sorted { lhs, rhs in + let lhsIndex = orderedTabIds.firstIndex(of: lhs) ?? .max + let rhsIndex = orderedTabIds.firstIndex(of: rhs) ?? .max + return lhsIndex < rhsIndex + } + if let selected, existing.contains(selected) { + ordered.removeAll { $0 == selected } + ordered.insert(selected, at: 0) + } + var pinnedInsertionIndex = (selected != nil) ? 1 : 0 + for pinnedId in prioritizedPinnedIds { + ordered.removeAll { $0 == pinnedId } + let insertionIndex = min(pinnedInsertionIndex, ordered.count) + ordered.insert(pinnedId, at: insertionIndex) + pinnedInsertionIndex += 1 + } + if ordered.count > clampedMax { ordered.removeSubrange(clampedMax...) } @@ -315,18 +337,10 @@ enum WorkspaceMountPolicy { } private static func cycleWarmIds(selected: UUID, orderedTabIds: [UUID]) -> [UUID] { - guard let selectedIndex = orderedTabIds.firstIndex(of: selected) else { - return [selected] - } - - var ids: [UUID] = [selected] - if selectedIndex > 0 { - ids.append(orderedTabIds[selectedIndex - 1]) - } - if selectedIndex + 1 < orderedTabIds.count { - ids.append(orderedTabIds[selectedIndex + 1]) - } - return ids + guard orderedTabIds.contains(selected) else { return [selected] } + // Keep warming focused to the selected workspace. Retiring/target workspaces are + // pinned by handoff logic, so warming adjacent neighbors here just adds layout work. + return [selected] } } @@ -465,10 +479,12 @@ struct ContentView: View { let isRetiringWorkspace = retiringWorkspaceId == tab.id let isInputActive = isSelectedWorkspace || isRetiringWorkspace let isVisible = isSelectedWorkspace || isRetiringWorkspace + let portalPriority = isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0) WorkspaceContentView( workspace: tab, isWorkspaceVisible: isVisible, - isWorkspaceInputActive: isInputActive + isWorkspaceInputActive: isInputActive, + workspacePortalPriority: portalPriority ) .opacity(isVisible ? 1 : 0) .allowsHitTesting(isSelectedWorkspace) @@ -821,7 +837,8 @@ struct ContentView: View { let effectiveSelectedId = selectedId ?? tabManager.selectedTabId let pinnedIds = retiringWorkspaceId.map { Set([ $0 ]) } ?? [] let isCycleHot = tabManager.isWorkspaceCycleHot - let baseMaxMounted = isCycleHot + let shouldKeepHandoffPair = isCycleHot && !pinnedIds.isEmpty + let baseMaxMounted = shouldKeepHandoffPair ? WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle : WorkspaceMountPolicy.maxMountedWorkspaces let selectedCount = effectiveSelectedId == nil ? 0 : 1 diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 68572571..5fe29ff4 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3670,6 +3670,7 @@ struct GhosttyTerminalView: NSViewRepresentable { let terminalSurface: TerminalSurface var isActive: Bool = true var isVisibleInUI: Bool = true + var portalZPriority: Int = 0 var showsInactiveOverlay: Bool = false var inactiveOverlayColor: NSColor = .clear var inactiveOverlayOpacity: Double = 0 @@ -3713,6 +3714,8 @@ struct GhosttyTerminalView: NSViewRepresentable { // Track the latest desired state so attach retries can re-apply focus after re-parenting. var desiredIsActive: Bool = true var desiredIsVisibleInUI: Bool = true + var desiredPortalZPriority: Int = 0 + var lastBoundHostId: ObjectIdentifier? weak var hostedView: GhosttySurfaceScrollView? } @@ -3729,23 +3732,30 @@ struct GhosttyTerminalView: NSViewRepresentable { func updateNSView(_ nsView: NSView, context: Context) { let hostedView = terminalSurface.hostedView let coordinator = context.coordinator +#if DEBUG let previousDesiredIsActive = coordinator.desiredIsActive +#endif let previousDesiredIsVisibleInUI = coordinator.desiredIsVisibleInUI + let previousDesiredPortalZPriority = coordinator.desiredPortalZPriority coordinator.desiredIsActive = isActive coordinator.desiredIsVisibleInUI = isVisibleInUI + coordinator.desiredPortalZPriority = portalZPriority coordinator.hostedView = hostedView #if DEBUG - if previousDesiredIsActive != isActive || previousDesiredIsVisibleInUI != isVisibleInUI { + if previousDesiredIsActive != isActive || + previousDesiredIsVisibleInUI != isVisibleInUI || + previousDesiredPortalZPriority != portalZPriority { if let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() { let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 dlog( "ws.swiftui.update id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) " + - "surface=\(terminalSurface.id.uuidString.prefix(5)) visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0)" + "surface=\(terminalSurface.id.uuidString.prefix(5)) visible=\(isVisibleInUI ? 1 : 0) " + + "active=\(isActive ? 1 : 0) z=\(portalZPriority)" ) } else { dlog( "ws.swiftui.update id=none surface=\(terminalSurface.id.uuidString.prefix(5)) " + - "visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0)" + "visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0) z=\(portalZPriority)" ) } } @@ -3774,28 +3784,36 @@ struct GhosttyTerminalView: NSViewRepresentable { TerminalWindowPortalRegistry.bind( hostedView: hostedView, to: host, - visibleInUI: coordinator.desiredIsVisibleInUI + visibleInUI: coordinator.desiredIsVisibleInUI, + zPriority: coordinator.desiredPortalZPriority ) + coordinator.lastBoundHostId = ObjectIdentifier(host) hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI) hostedView.setActive(coordinator.desiredIsActive) } - host.onGeometryChanged = { [weak host, weak hostedView, weak coordinator] in - guard let host, let hostedView, let coordinator else { return } + host.onGeometryChanged = { [weak host, weak coordinator] in + guard let host, let coordinator else { return } guard coordinator.attachGeneration == generation else { return } - TerminalWindowPortalRegistry.bind( - hostedView: hostedView, - to: host, - visibleInUI: coordinator.desiredIsVisibleInUI - ) + guard coordinator.lastBoundHostId == ObjectIdentifier(host) else { return } TerminalWindowPortalRegistry.synchronizeForAnchor(host) } if host.window != nil { - TerminalWindowPortalRegistry.bind( - hostedView: hostedView, - to: host, - visibleInUI: coordinator.desiredIsVisibleInUI - ) + let hostId = ObjectIdentifier(host) + let shouldBindNow = + coordinator.lastBoundHostId != hostId || + hostedView.superview == nil || + previousDesiredIsVisibleInUI != isVisibleInUI || + previousDesiredPortalZPriority != portalZPriority + if shouldBindNow { + TerminalWindowPortalRegistry.bind( + hostedView: hostedView, + to: host, + visibleInUI: coordinator.desiredIsVisibleInUI, + zPriority: coordinator.desiredPortalZPriority + ) + coordinator.lastBoundHostId = hostId + } TerminalWindowPortalRegistry.synchronizeForAnchor(host) } } @@ -3805,6 +3823,8 @@ struct GhosttyTerminalView: NSViewRepresentable { coordinator.attachGeneration += 1 coordinator.desiredIsActive = false coordinator.desiredIsVisibleInUI = false + coordinator.desiredPortalZPriority = 0 + coordinator.lastBoundHostId = nil #if DEBUG if let hostedView = coordinator.hostedView { if let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() { diff --git a/Sources/Panels/PanelContentView.swift b/Sources/Panels/PanelContentView.swift index 4411ed74..9049e166 100644 --- a/Sources/Panels/PanelContentView.swift +++ b/Sources/Panels/PanelContentView.swift @@ -7,6 +7,7 @@ struct PanelContentView: View { let isFocused: Bool let isSelectedInPane: Bool let isVisibleInUI: Bool + let portalPriority: Int let isSplit: Bool let appearance: PanelAppearance let notificationStore: TerminalNotificationStore @@ -22,6 +23,7 @@ struct PanelContentView: View { panel: terminalPanel, isFocused: isFocused, isVisibleInUI: isVisibleInUI, + portalPriority: portalPriority, isSplit: isSplit, appearance: appearance, notificationStore: notificationStore, diff --git a/Sources/Panels/TerminalPanelView.swift b/Sources/Panels/TerminalPanelView.swift index eaf2f0ba..4486d724 100644 --- a/Sources/Panels/TerminalPanelView.swift +++ b/Sources/Panels/TerminalPanelView.swift @@ -7,6 +7,7 @@ struct TerminalPanelView: View { @ObservedObject var panel: TerminalPanel let isFocused: Bool let isVisibleInUI: Bool + let portalPriority: Int let isSplit: Bool let appearance: PanelAppearance let notificationStore: TerminalNotificationStore @@ -19,6 +20,7 @@ struct TerminalPanelView: View { terminalSurface: panel.surface, isActive: isFocused, isVisibleInUI: isVisibleInUI, + portalZPriority: portalPriority, showsInactiveOverlay: isSplit && !isFocused, inactiveOverlayColor: appearance.unfocusedOverlayNSColor, inactiveOverlayOpacity: appearance.unfocusedOverlayOpacity, diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 7faf6949..5904a7aa 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -25,6 +25,7 @@ final class WindowTerminalPortal: NSObject { weak var hostedView: GhosttySurfaceScrollView? weak var anchorView: NSView? var visibleInUI: Bool + var zPriority: Int } private var entriesByHostedId: [ObjectIdentifier: Entry] = [:] @@ -61,13 +62,14 @@ final class WindowTerminalPortal: NSObject { NSLayoutConstraint.activate(installConstraints) installedContainerView = container installedReferenceView = reference - } else { + } else if !Self.isView(hostView, above: reference, in: container) { container.addSubview(hostView, positioned: .above, relativeTo: reference) } // Keep the drag/mouse forwarding overlay above portal-hosted terminal views. if let overlay = objc_getAssociatedObject(window, &fileDropOverlayKey) as? NSView, - overlay.superview === container { + overlay.superview === container, + !Self.isView(overlay, above: hostView, in: container) { container.addSubview(overlay, positioned: .above, relativeTo: hostView) } @@ -105,6 +107,14 @@ final class WindowTerminalPortal: NSObject { abs(lhs.size.height - rhs.size.height) <= epsilon } + private static func isView(_ view: NSView, above reference: NSView, in container: NSView) -> Bool { + guard let viewIndex = container.subviews.firstIndex(of: view), + let referenceIndex = container.subviews.firstIndex(of: reference) else { + return false + } + return viewIndex > referenceIndex + } + func detachHostedView(withId hostedId: ObjectIdentifier) { guard let entry = entriesByHostedId.removeValue(forKey: hostedId) else { return } if let anchor = entry.anchorView { @@ -115,11 +125,12 @@ final class WindowTerminalPortal: NSObject { } } - func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool) { + func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) { guard ensureInstalled() else { return } let hostedId = ObjectIdentifier(hostedView) let anchorId = ObjectIdentifier(anchorView) + let previousEntry = entriesByHostedId[hostedId] if let previousHostedId = hostedByAnchorId[anchorId], previousHostedId != hostedId { detachHostedView(withId: previousHostedId) @@ -135,12 +146,25 @@ final class WindowTerminalPortal: NSObject { entriesByHostedId[hostedId] = Entry( hostedView: hostedView, anchorView: anchorView, - visibleInUI: visibleInUI + visibleInUI: visibleInUI, + zPriority: zPriority ) + let didChangeAnchor: Bool = { + guard let previousAnchor = previousEntry?.anchorView else { return true } + return previousAnchor !== anchorView + }() + let becameVisible = (previousEntry?.visibleInUI ?? false) == false && visibleInUI + let priorityIncreased = zPriority > (previousEntry?.zPriority ?? Int.min) + if hostedView.superview !== hostView { hostedView.removeFromSuperview() hostView.addSubview(hostedView) + } else if (didChangeAnchor || becameVisible || priorityIncreased), hostView.subviews.last !== hostedView { + // Refresh z-order only on meaningful transitions. Reordering on every bind call + // creates expensive reparent loops during SwiftUI update/layout churn. + hostedView.removeFromSuperview() + hostView.addSubview(hostedView) } synchronizeHostedView(withId: hostedId) @@ -347,7 +371,7 @@ enum TerminalWindowPortalRegistry { return portal } - static func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool) { + static func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) { guard let window = anchorView.window else { return } let windowId = ObjectIdentifier(window) @@ -359,7 +383,7 @@ enum TerminalWindowPortalRegistry { portalsByWindowId[oldWindowId]?.detachHostedView(withId: hostedId) } - nextPortal.bind(hostedView: hostedView, to: anchorView, visibleInUI: visibleInUI) + nextPortal.bind(hostedView: hostedView, to: anchorView, visibleInUI: visibleInUI, zPriority: zPriority) hostedToWindowId[hostedId] = windowId pruneHostedMappings(for: windowId, validHostedIds: nextPortal.hostedIds()) } diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 92fc0a42..f977ea65 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -7,6 +7,7 @@ struct WorkspaceContentView: View { @ObservedObject var workspace: Workspace let isWorkspaceVisible: Bool let isWorkspaceInputActive: Bool + let workspacePortalPriority: Int @State private var config = GhosttyConfig.load() @EnvironmentObject var notificationStore: TerminalNotificationStore @@ -44,6 +45,7 @@ struct WorkspaceContentView: View { isFocused: isFocused, isSelectedInPane: isSelectedInPane, isVisibleInUI: isVisibleInUI, + portalPriority: workspacePortalPriority, isSplit: isSplit, appearance: appearance, notificationStore: notificationStore, diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 3bb81093..0acac8b6 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1803,7 +1803,7 @@ final class WorkspaceMountPolicyTests: XCTestCase { XCTAssertEqual(next, [a]) } - func testCycleHotModeWarmsSelectedAndImmediateNeighbors() { + func testCycleHotModeKeepsOnlySelectedWhenNoPinnedHandoff() { let a = UUID() let b = UUID() let c = UUID() @@ -1819,7 +1819,7 @@ final class WorkspaceMountPolicyTests: XCTestCase { maxMounted: WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle ) - XCTAssertEqual(next, [c, b, d]) + XCTAssertEqual(next, [c]) } func testCycleHotModeRespectsMaxMountedLimit() { @@ -1837,7 +1837,7 @@ final class WorkspaceMountPolicyTests: XCTestCase { maxMounted: 2 ) - XCTAssertEqual(next, [b, a]) + XCTAssertEqual(next, [b]) } func testPinnedIdsAreRetainedAcrossReconcile() { @@ -1857,6 +1857,23 @@ final class WorkspaceMountPolicyTests: XCTestCase { 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 @@ -1903,6 +1920,35 @@ final class GhosttySurfaceOverlayTests: XCTestCase { @MainActor final class TerminalWindowPortalLifecycleTests: XCTestCase { + 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 testRegistryPrunesPortalWhenWindowCloses() { let baseline = TerminalWindowPortalRegistry.debugPortalCount() let window = NSWindow( @@ -1982,4 +2028,85 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { "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" + ) + } }