Merge pull request #83 from manaflow-ai/perf/portal-hosting-selected-mount

Reduce terminal input latency via portal hosting + selected-only workspace mounting
This commit is contained in:
Lawrence Chen 2026-02-18 22:31:45 -08:00 committed by GitHub
commit d08f28d770
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1798 additions and 182 deletions

View file

@ -83,4 +83,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

View file

@ -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

View file

@ -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

View file

@ -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 */; };
@ -123,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 = "<group>"; };
A5001012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSelectionState.swift; sourceTree = "<group>"; };
@ -132,6 +133,7 @@
A5001013 /* TabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManager.swift; sourceTree = "<group>"; };
A5001014 /* GhosttyConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfig.swift; sourceTree = "<group>"; };
A5001015 /* GhosttyTerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalView.swift; sourceTree = "<group>"; };
A5001531 /* TerminalWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindowPortal.swift; sourceTree = "<group>"; };
A5001016 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = "<group>"; };
A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = "<group>"; };
A5001018 /* cmux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "cmux-Bridging-Header.h"; sourceTree = "<group>"; };
@ -282,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 = "<group>";
@ -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 */,
@ -359,13 +362,13 @@
children = (
A5001000 /* cmux.app */,
B9000004A1B2C3D4E5F60719 /* cmux */,
7E7E6EF344A568AC7FEE3715 /* GhosttyTabsUITests.xctest */,
F1000002A1B2C3D4E5F60718 /* GhosttyTabsTests.xctest */,
7E7E6EF344A568AC7FEE3715 /* cmuxUITests.xctest */,
F1000002A1B2C3D4E5F60718 /* cmuxTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
3196C9C2D01F054C1D3385DD /* GhosttyTabsUITests */ = {
3196C9C2D01F054C1D3385DD /* cmuxUITests */ = {
isa = PBXGroup;
children = (
B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */,
@ -379,16 +382,16 @@
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */,
E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */,
);
path = GhosttyTabsUITests;
path = cmuxUITests;
sourceTree = "<group>";
};
F1000003A1B2C3D4E5F60718 /* GhosttyTabsTests */ = {
F1000003A1B2C3D4E5F60718 /* cmuxTests */ = {
isa = PBXGroup;
children = (
F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */,
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */,
);
path = GhosttyTabsTests;
path = cmuxTests;
sourceTree = "<group>";
};
/* End PBXGroup section */
@ -437,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 */,
@ -450,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 */,
@ -468,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 */
@ -504,8 +507,8 @@
targets = (
A5001050 /* GhosttyTabs */,
B9000005A1B2C3D4E5F60719 /* cmux-cli */,
CB450DF0F0B3839599082C4D /* GhosttyTabsUITests */,
F1000004A1B2C3D4E5F60718 /* GhosttyTabsTests */,
CB450DF0F0B3839599082C4D /* cmuxUITests */,
F1000004A1B2C3D4E5F60718 /* cmuxTests */,
);
};
/* End PBXProject section */
@ -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 */,
@ -916,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 */,
@ -925,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 */,

View file

@ -6,14 +6,14 @@
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="A5001050" BuildableName="cmux.app" BlueprintName="GhosttyTabs" ReferencedContainer="container:GhosttyTabs.xcodeproj"/>
</BuildActionEntry>
<BuildActionEntry buildForTesting="YES" buildForRunning="NO" buildForProfiling="NO" buildForArchiving="NO" buildForAnalyzing="NO">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="F1000004A1B2C3D4E5F60718" BuildableName="GhosttyTabsTests.xctest" BlueprintName="GhosttyTabsTests" ReferencedContainer="container:GhosttyTabs.xcodeproj"/>
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="F1000004A1B2C3D4E5F60718" BuildableName="cmuxTests.xctest" BlueprintName="cmuxTests" ReferencedContainer="container:GhosttyTabs.xcodeproj"/>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES">
<Testables>
<TestableReference skipped="NO">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="F1000004A1B2C3D4E5F60718" BuildableName="GhosttyTabsTests.xctest" BlueprintName="GhosttyTabsTests" ReferencedContainer="container:GhosttyTabs.xcodeproj"/>
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="F1000004A1B2C3D4E5F60718" BuildableName="cmuxTests.xctest" BlueprintName="cmuxTests" ReferencedContainer="container:GhosttyTabs.xcodeproj"/>
</TestableReference>
</Testables>
<MacroExpansion>

View file

@ -10,7 +10,7 @@
<TestAction buildConfiguration="Release" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES">
<Testables>
<TestableReference skipped="NO">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="CB450DF0F0B3839599082C4D" BuildableName="GhosttyTabsUITests.xctest" BlueprintName="GhosttyTabsUITests" ReferencedContainer="container:GhosttyTabs.xcodeproj"/>
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="CB450DF0F0B3839599082C4D" BuildableName="cmuxUITests.xctest" BlueprintName="cmuxUITests" ReferencedContainer="container:GhosttyTabs.xcodeproj"/>
</TestableReference>
</Testables>
<MacroExpansion>

View file

@ -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
@ -1744,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
}

View file

@ -258,8 +258,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
@ -278,6 +283,79 @@ 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
// During workspace cycling, keep only a minimal handoff pair (selected + retiring).
static let maxMountedWorkspacesDuringCycle = 2
static func nextMountedWorkspaceIds(
current: [UUID],
selected: UUID?,
pinnedIds: Set<UUID>,
orderedTabIds: [UUID],
isCycleHot: Bool,
maxMounted: Int
) -> [UUID] {
let existing = Set(orderedTabIds)
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 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 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...)
}
return ordered
}
private static func cycleWarmIds(selected: UUID, orderedTabIds: [UUID]) -> [UUID] {
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]
}
}
/// 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,
@ -318,11 +396,16 @@ struct ContentView: View {
@State private var isResizerDragging = false
private let sidebarHandleWidth: CGFloat = 6
@State private var selectedTabIds: Set<UUID> = []
@State private var mountedWorkspaceIds: [UUID] = []
@State private var lastSidebarSelectionIndex: Int? = nil
@State private var titlebarText: String = ""
@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<Void, Never>?
private var sidebarView: some View {
VerticalTabsSidebar(
@ -396,13 +479,28 @@ 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) }
let selectedWorkspaceId = tabManager.selectedTabId
let retiringWorkspaceId = self.retiringWorkspaceId
return ZStack {
ZStack {
ForEach(tabManager.tabs) { tab in
let isActive = tabManager.selectedTabId == tab.id
WorkspaceContentView(workspace: tab, isTabActive: isActive)
.opacity(isActive ? 1 : 0)
.allowsHitTesting(isActive)
ForEach(mountedWorkspaces) { tab in
let isSelectedWorkspace = selectedWorkspaceId == tab.id
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,
workspacePortalPriority: portalPriority
)
.opacity(isVisible ? 1 : 0)
.allowsHitTesting(isSelectedWorkspace)
.zIndex(isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0))
}
}
.opacity(sidebarSelectionState.selection == .tabs ? 1 : 0)
@ -566,6 +664,8 @@ struct ContentView: View {
.background(Color.clear)
.onAppear {
tabManager.applyWindowBackgroundForSelectedTab()
reconcileMountedWorkspaceIds()
previousSelectedWorkspaceId = tabManager.selectedTabId
if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId {
selectedTabIds = [selectedId]
lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId }
@ -573,7 +673,19 @@ 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 {
selectedTabIds = [newValue]
@ -581,6 +693,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 }
@ -593,10 +721,25 @@ 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 {
selectedTabIds = [selectedId]
@ -700,6 +843,49 @@ struct ContentView: View {
})
}
private func reconcileMountedWorkspaceIds(tabs: [Workspace]? = nil, selectedId: UUID? = nil) {
let currentTabs = tabs ?? tabManager.tabs
let orderedTabIds = currentTabs.map { $0.id }
let effectiveSelectedId = selectedId ?? tabManager.selectedTabId
let pinnedIds = retiringWorkspaceId.map { Set([ $0 ]) } ?? []
let isCycleHot = tabManager.isWorkspaceCycleHot
let shouldKeepHandoffPair = isCycleHot && !pinnedIds.isEmpty
let baseMaxMounted = shouldKeepHandoffPair
? 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,
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() {
tabManager.addTab()
sidebarSelectionState.selection = .tabs
@ -721,6 +907,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 {

View file

@ -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?
@ -1834,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.
@ -1867,6 +1885,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 +1905,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 +2017,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
super.keyDown(with: event)
return
}
#if DEBUG
recordKeyLatency(path: "keyDown", event: event)
#endif
#if DEBUG
cmuxWriteChildExitProbe(
@ -2633,6 +2667,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] = []
@ -2709,12 +2744,17 @@ final class GhosttySurfaceScrollView: NSView {
}
return (presentCounts[surfaceId, default: 0], lastPresentTimes[surfaceId, default: 0], key)
}
var debugSurfaceId: UUID? {
surfaceView.terminalSurface?.id
}
#endif
init(surfaceView: GhosttyNSView) {
self.surfaceView = surfaceView
backgroundView = NSView(frame: .zero)
scrollView = GhosttyScrollView()
inactiveOverlayView = GhosttyFlashOverlayView(frame: .zero)
flashOverlayView = GhosttyFlashOverlayView(frame: .zero)
flashLayer = CAShapeLayer()
scrollView.hasVerticalScroller = true
@ -2742,6 +2782,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
@ -2854,6 +2898,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()
@ -2903,6 +2948,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 }
@ -2929,7 +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,
@ -2942,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,
@ -2952,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")")
@ -2986,6 +3070,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.
@ -3001,6 +3092,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),
@ -3574,30 +3670,53 @@ 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
var reattachToken: UInt64 = 0
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
var desiredIsVisibleInUI: Bool = true
var desiredPortalZPriority: Int = 0
var lastBoundHostId: ObjectIdentifier?
weak var hostedView: GhosttySurfaceScrollView?
}
func makeCoordinator() -> Coordinator {
@ -3606,111 +3725,130 @@ 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
#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 ||
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) z=\(portalZPriority)"
)
} else {
dlog(
"ws.swiftui.update id=none surface=\(terminalSurface.id.uuidString.prefix(5)) " +
"visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0) z=\(portalZPriority)"
)
}
}
#endif
// Keep the surface lifecycle and handlers updated even if we defer re-parenting.
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)
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,
zPriority: coordinator.desiredPortalZPriority
)
coordinator.lastBoundHostId = ObjectIdentifier(host)
hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI)
hostedView.setActive(coordinator.desiredIsActive)
}
host.onGeometryChanged = { [weak host, weak coordinator] in
guard let host, let coordinator else { return }
guard coordinator.attachGeneration == generation else { return }
guard coordinator.lastBoundHostId == ObjectIdentifier(host) else { return }
TerminalWindowPortalRegistry.synchronizeForAnchor(host)
}
if host.window != nil {
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
}
}
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
TerminalWindowPortalRegistry.synchronizeForAnchor(host)
}
}
}
static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) {
coordinator.attachGeneration += 1
NSLayoutConstraint.deactivate(coordinator.constraints)
coordinator.constraints.removeAll()
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() {
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
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() }
}
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() }
}
}

View file

@ -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,

View file

@ -1,11 +1,13 @@
import SwiftUI
import Foundation
import AppKit
/// View for rendering a terminal panel
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
@ -18,6 +20,10 @@ struct TerminalPanelView: View {
terminalSurface: panel.surface,
isActive: isFocused,
isVisibleInUI: isVisibleInUI,
portalZPriority: portalPriority,
showsInactiveOverlay: isSplit && !isFocused,
inactiveOverlayColor: appearance.unfocusedOverlayNSColor,
inactiveOverlayOpacity: appearance.unfocusedOverlayOpacity,
reattachToken: panel.viewReattachToken,
onFocus: { _ in onFocus() },
onTriggerFlash: onTriggerFlash
@ -27,14 +33,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()
@ -62,13 +60,13 @@ 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
)
}

View file

@ -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<Void, Never>?
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")

View file

@ -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)"

View file

@ -0,0 +1,412 @@
import AppKit
import ObjectiveC
private var cmuxWindowTerminalPortalKey: UInt8 = 0
private var cmuxWindowTerminalPortalCloseObserverKey: 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
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
var zPriority: Int
}
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 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,
!Self.isView(overlay, above: hostView, in: 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
}
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 {
hostedByAnchorId.removeValue(forKey: ObjectIdentifier(anchor))
}
if let hostedView = entry.hostedView, hostedView.superview === hostView {
hostedView.removeFromSuperview()
}
}
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)
}
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,
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)
pruneDeadEntries()
}
func synchronizeHostedViewForAnchor(_ anchorView: NSView) {
pruneDeadEntries()
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 currentWindow = window
let deadHostedIds = entriesByHostedId.compactMap { hostedId, entry -> ObjectIdentifier? in
guard entry.hostedView != nil else { return hostedId }
guard let anchor = entry.anchorView else { return hostedId }
if anchor.window !== currentWindow || anchor.superview == nil {
return hostedId
}
return nil
}
for hostedId in deadHostedIds {
detachHostedView(withId: hostedId)
}
let validAnchorIds = Set(entriesByHostedId.compactMap { _, entry in
entry.anchorView.map { ObjectIdentifier($0) }
})
hostedByAnchorId = hostedByAnchorId.filter { validAnchorIds.contains($0.key) }
}
func hostedIds() -> Set<ObjectIdentifier> {
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)
// 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 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
}
}
@MainActor
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<ObjectIdentifier>) {
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
}
static func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) {
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, zPriority: zPriority)
hostedToWindowId[hostedId] = windowId
pruneHostedMappings(for: windowId, validHostedIds: nextPortal.hostedIds())
}
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)
}
#if DEBUG
static func debugPortalCount() -> Int {
portalsByWindowId.count
}
#endif
}

View file

@ -5,7 +5,9 @@ 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
let workspacePortalPriority: Int
@State private var config = GhosttyConfig.load()
@EnvironmentObject var notificationStore: TerminalNotificationStore
@ -16,7 +18,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,14 +37,15 @@ 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,
isSelectedInPane: isSelectedInPane,
isVisibleInUI: isVisibleInUI,
portalPriority: workspacePortalPriority,
isSplit: isSplit,
appearance: appearance,
notificationStore: notificationStore,
@ -50,12 +53,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)
},

View file

@ -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() {
@ -1687,3 +1716,397 @@ 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 orderedTabIds: [UUID] = [a, b]
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
current: [a],
selected: b,
pinnedIds: [],
orderedTabIds: orderedTabIds,
isCycleHot: false,
maxMounted: WorkspaceMountPolicy.maxMountedWorkspaces
)
XCTAssertEqual(next, [b])
}
func testSelectedWorkspaceMovesToFrontAndMountCountIsBounded() {
let a = UUID()
let b = UUID()
let c = UUID()
let orderedTabIds: [UUID] = [a, b, c]
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
current: [a, b, c],
selected: c,
pinnedIds: [],
orderedTabIds: orderedTabIds,
isCycleHot: false,
maxMounted: 2
)
XCTAssertEqual(next, [c, a])
}
func testMissingWorkspacesArePruned() {
let a = UUID()
let b = UUID()
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
current: [b, a],
selected: nil,
pinnedIds: [],
orderedTabIds: [a],
isCycleHot: false,
maxMounted: 2
)
XCTAssertEqual(next, [a])
}
func testSelectedWorkspaceIsInsertedWhenAbsentFromCurrentCache() {
let a = UUID()
let b = UUID()
let orderedTabIds: [UUID] = [a, b]
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
current: [a],
selected: b,
pinnedIds: [],
orderedTabIds: orderedTabIds,
isCycleHot: false,
maxMounted: 2
)
XCTAssertEqual(next, [b, a])
}
func testMaxMountedIsClampedToAtLeastOne() {
let a = UUID()
let b = UUID()
let orderedTabIds: [UUID] = [a, b]
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
current: [a, b],
selected: nil,
pinnedIds: [],
orderedTabIds: orderedTabIds,
isCycleHot: false,
maxMounted: 0
)
XCTAssertEqual(next, [a])
}
func testCycleHotModeKeepsOnlySelectedWhenNoPinnedHandoff() {
let a = UUID()
let b = UUID()
let c = UUID()
let d = UUID()
let orderedTabIds: [UUID] = [a, b, c, d]
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
current: [a],
selected: c,
pinnedIds: [],
orderedTabIds: orderedTabIds,
isCycleHot: true,
maxMounted: WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle
)
XCTAssertEqual(next, [c])
}
func testCycleHotModeRespectsMaxMountedLimit() {
let a = UUID()
let b = UUID()
let c = UUID()
let orderedTabIds: [UUID] = [a, b, c]
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
current: [a, b, c],
selected: b,
pinnedIds: [],
orderedTabIds: orderedTabIds,
isCycleHot: true,
maxMounted: 2
)
XCTAssertEqual(next, [b])
}
func testPinnedIdsAreRetainedAcrossReconcile() {
let a = UUID()
let b = UUID()
let c = UUID()
let orderedTabIds: [UUID] = [a, b, c]
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
current: [a],
selected: c,
pinnedIds: [a],
orderedTabIds: orderedTabIds,
isCycleHot: false,
maxMounted: 2
)
XCTAssertEqual(next, [c, a])
}
func testCycleHotModeKeepsRetiringWorkspaceWhenPinned() {
let a = UUID()
let b = UUID()
let orderedTabIds: [UUID] = [a, b]
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
current: [a],
selected: b,
pinnedIds: [a],
orderedTabIds: orderedTabIds,
isCycleHot: true,
maxMounted: WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle
)
XCTAssertEqual(next, [b, a])
}
}
@MainActor
final class WindowTerminalHostViewTests: XCTestCase {
private final class CapturingView: NSView {
override func hitTest(_ point: NSPoint) -> NSView? {
bounds.contains(point) ? self : nil
}
}
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)))
}
}
@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 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(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
_ = TerminalWindowPortalRegistry.viewAtWindowPoint(NSPoint(x: 1, y: 1), in: window)
XCTAssertEqual(TerminalWindowPortalRegistry.debugPortalCount(), baseline + 1)
NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window)
XCTAssertEqual(TerminalWindowPortalRegistry.debugPortalCount(), baseline)
}
func testPruneDeadEntriesDetachesAnchorlessHostedView() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
let portal = WindowTerminalPortal(window: window)
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let hosted1 = GhosttySurfaceScrollView(
surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 40, height: 30))
)
var anchor1: NSView? = NSView(frame: NSRect(x: 20, y: 20, width: 120, height: 80))
contentView.addSubview(anchor1!)
portal.bind(hostedView: hosted1, to: anchor1!, visibleInUI: true)
anchor1?.removeFromSuperview()
anchor1 = nil
let hosted2 = GhosttySurfaceScrollView(
surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 40, height: 30))
)
let anchor2 = NSView(frame: NSRect(x: 180, y: 20, width: 120, height: 80))
contentView.addSubview(anchor2)
portal.bind(hostedView: hosted2, to: anchor2, visibleInUI: true)
XCTAssertEqual(portal.debugEntryCount(), 1, "Only the live anchored hosted view should remain tracked")
XCTAssertEqual(portal.debugHostedSubviewCount(), 1, "Stale anchorless hosted views should be detached from hostView")
}
func testTerminalViewAtWindowPointResolvesPortalHostedSurface() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
let portal = WindowTerminalPortal(window: window)
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let anchor = NSView(frame: NSRect(x: 40, y: 50, width: 200, height: 120))
contentView.addSubview(anchor)
let hosted = GhosttySurfaceScrollView(
surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 100, height: 80))
)
portal.bind(hostedView: hosted, to: anchor, visibleInUI: true)
let center = NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY)
let windowPoint = anchor.convert(center, to: nil)
XCTAssertNotNil(
portal.terminalViewAtWindowPoint(windowPoint),
"Portal hit-testing should resolve the terminal view for Finder file drops"
)
}
func testVisibilityTransitionBringsHostedViewToFront() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
let portal = WindowTerminalPortal(window: window)
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let anchor1 = NSView(frame: NSRect(x: 20, y: 20, width: 220, height: 180))
let anchor2 = NSView(frame: NSRect(x: 80, y: 60, width: 220, height: 180))
contentView.addSubview(anchor1)
contentView.addSubview(anchor2)
let terminal1 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80))
let hosted1 = GhosttySurfaceScrollView(surfaceView: terminal1)
let terminal2 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80))
let hosted2 = GhosttySurfaceScrollView(surfaceView: terminal2)
portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true)
portal.bind(hostedView: hosted2, to: anchor2, visibleInUI: true)
let overlapInContent = NSPoint(x: 120, y: 100)
let overlapInWindow = contentView.convert(overlapInContent, to: nil)
XCTAssertTrue(
portal.terminalViewAtWindowPoint(overlapInWindow) === terminal2,
"Latest bind should be top-most before visibility transition"
)
portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: false)
portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true)
XCTAssertTrue(
portal.terminalViewAtWindowPoint(overlapInWindow) === terminal1,
"Becoming visible should refresh z-order for already-hosted view"
)
}
func testPriorityIncreaseBringsHostedViewToFrontWithoutVisibilityToggle() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
let portal = WindowTerminalPortal(window: window)
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let anchor1 = NSView(frame: NSRect(x: 20, y: 20, width: 220, height: 180))
let anchor2 = NSView(frame: NSRect(x: 80, y: 60, width: 220, height: 180))
contentView.addSubview(anchor1)
contentView.addSubview(anchor2)
let terminal1 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80))
let hosted1 = GhosttySurfaceScrollView(surfaceView: terminal1)
let terminal2 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80))
let hosted2 = GhosttySurfaceScrollView(surfaceView: terminal2)
portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true, zPriority: 1)
portal.bind(hostedView: hosted2, to: anchor2, visibleInUI: true, zPriority: 2)
let overlapInContent = NSPoint(x: 120, y: 100)
let overlapInWindow = contentView.convert(overlapInContent, to: nil)
XCTAssertTrue(
portal.terminalViewAtWindowPoint(overlapInWindow) === terminal2,
"Higher-priority terminal should initially be top-most"
)
portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true, zPriority: 2)
XCTAssertTrue(
portal.terminalViewAtWindowPoint(overlapInWindow) === terminal1,
"Promoting z-priority should bring an already-visible terminal to front"
)
}
}

21
scripts/test-unit.sh Executable file
View file

@ -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" \
"$@"

View file

@ -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

View file

@ -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())