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

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