Stabilize rapid workspace switching handoff

This commit is contained in:
Lawrence Chen 2026-02-18 21:17:53 -08:00
parent a723bbaa6a
commit 7aa80b9cdc
7 changed files with 664 additions and 30 deletions

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() {
@ -1692,12 +1721,15 @@ final class WorkspaceMountPolicyTests: XCTestCase {
func testDefaultPolicyMountsOnlySelectedWorkspace() {
let a = UUID()
let b = UUID()
let existing: Set<UUID> = [a, b]
let orderedTabIds: [UUID] = [a, b]
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
current: [a],
selected: b,
existing: existing
pinnedIds: [],
orderedTabIds: orderedTabIds,
isCycleHot: false,
maxMounted: WorkspaceMountPolicy.maxMountedWorkspaces
)
XCTAssertEqual(next, [b])
@ -1707,12 +1739,14 @@ final class WorkspaceMountPolicyTests: XCTestCase {
let a = UUID()
let b = UUID()
let c = UUID()
let existing: Set<UUID> = [a, b, c]
let orderedTabIds: [UUID] = [a, b, c]
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
current: [a, b, c],
selected: c,
existing: existing,
pinnedIds: [],
orderedTabIds: orderedTabIds,
isCycleHot: false,
maxMounted: 2
)
@ -1726,7 +1760,9 @@ final class WorkspaceMountPolicyTests: XCTestCase {
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
current: [b, a],
selected: nil,
existing: [a],
pinnedIds: [],
orderedTabIds: [a],
isCycleHot: false,
maxMounted: 2
)
@ -1736,12 +1772,14 @@ final class WorkspaceMountPolicyTests: XCTestCase {
func testSelectedWorkspaceIsInsertedWhenAbsentFromCurrentCache() {
let a = UUID()
let b = UUID()
let existing: Set<UUID> = [a, b]
let orderedTabIds: [UUID] = [a, b]
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
current: [a],
selected: b,
existing: existing,
pinnedIds: [],
orderedTabIds: orderedTabIds,
isCycleHot: false,
maxMounted: 2
)
@ -1751,17 +1789,74 @@ final class WorkspaceMountPolicyTests: XCTestCase {
func testMaxMountedIsClampedToAtLeastOne() {
let a = UUID()
let b = UUID()
let existing: Set<UUID> = [a, b]
let orderedTabIds: [UUID] = [a, b]
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
current: [a, b],
selected: nil,
existing: existing,
pinnedIds: [],
orderedTabIds: orderedTabIds,
isCycleHot: false,
maxMounted: 0
)
XCTAssertEqual(next, [a])
}
func testCycleHotModeWarmsSelectedAndImmediateNeighbors() {
let a = UUID()
let b = UUID()
let c = UUID()
let d = UUID()
let orderedTabIds: [UUID] = [a, b, c, d]
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
current: [a],
selected: c,
pinnedIds: [],
orderedTabIds: orderedTabIds,
isCycleHot: true,
maxMounted: WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle
)
XCTAssertEqual(next, [c, b, d])
}
func testCycleHotModeRespectsMaxMountedLimit() {
let a = UUID()
let b = UUID()
let c = UUID()
let orderedTabIds: [UUID] = [a, b, c]
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
current: [a, b, c],
selected: b,
pinnedIds: [],
orderedTabIds: orderedTabIds,
isCycleHot: true,
maxMounted: 2
)
XCTAssertEqual(next, [b, a])
}
func testPinnedIdsAreRetainedAcrossReconcile() {
let a = UUID()
let b = UUID()
let c = UUID()
let orderedTabIds: [UUID] = [a, b, c]
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
current: [a],
selected: c,
pinnedIds: [a],
orderedTabIds: orderedTabIds,
isCycleHot: false,
maxMounted: 2
)
XCTAssertEqual(next, [c, a])
}
}
@MainActor

View file

@ -1751,11 +1751,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
// Workspace navigation: Cmd+Ctrl+] / Cmd+Ctrl+[
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .nextSidebarTab)) {
#if DEBUG
let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil"
dlog(
"ws.shortcut dir=next repeat=\(event.isARepeat ? 1 : 0) keyCode=\(event.keyCode) selected=\(selected)"
)
#endif
tabManager?.selectNextTab()
return true
}
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .prevSidebarTab)) {
#if DEBUG
let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil"
dlog(
"ws.shortcut dir=prev repeat=\(event.isARepeat ? 1 : 0) keyCode=\(event.keyCode) selected=\(selected)"
)
#endif
tabManager?.selectPreviousTab()
return true
}

View file

@ -274,13 +274,17 @@ var fileDropOverlayKey: UInt8 = 0
enum WorkspaceMountPolicy {
// Keep only the selected workspace mounted to minimize layer-tree traversal.
static let maxMountedWorkspaces = 1
static let maxMountedWorkspacesDuringCycle = 3
static func nextMountedWorkspaceIds(
current: [UUID],
selected: UUID?,
existing: Set<UUID>,
maxMounted: Int = maxMountedWorkspaces
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) }
@ -289,12 +293,41 @@ enum WorkspaceMountPolicy {
ordered.insert(selected, at: 0)
}
let prioritizedPinnedIds = pinnedIds.filter { existing.contains($0) && $0 != selected }
for pinnedId in prioritizedPinnedIds.reversed() {
ordered.removeAll { $0 == pinnedId }
ordered.insert(pinnedId, at: 0)
}
if isCycleHot, let selected {
let warmIds = cycleWarmIds(selected: selected, orderedTabIds: orderedTabIds)
for id in warmIds.reversed() {
ordered.removeAll { $0 == id }
ordered.insert(id, at: 0)
}
}
if ordered.count > clampedMax {
ordered.removeSubrange(clampedMax...)
}
return ordered
}
private static func cycleWarmIds(selected: UUID, orderedTabIds: [UUID]) -> [UUID] {
guard let selectedIndex = orderedTabIds.firstIndex(of: selected) else {
return [selected]
}
var ids: [UUID] = [selected]
if selectedIndex > 0 {
ids.append(orderedTabIds[selectedIndex - 1])
}
if selectedIndex + 1 < orderedTabIds.count {
ids.append(orderedTabIds[selectedIndex + 1])
}
return ids
}
}
/// Installs a FileDropOverlayView on the window's theme frame for Finder file drag support.
@ -343,6 +376,10 @@ struct ContentView: View {
@State private var isFullScreen: Bool = false
@State private var observedWindow: NSWindow?
@StateObject private var fullscreenControlsViewModel = TitlebarControlsViewModel()
@State private var previousSelectedWorkspaceId: UUID?
@State private var retiringWorkspaceId: UUID?
@State private var workspaceHandoffGeneration: UInt64 = 0
@State private var workspaceHandoffFallbackTask: Task<Void, Never>?
private var sidebarView: some View {
VerticalTabsSidebar(
@ -418,14 +455,24 @@ struct ContentView: View {
private var terminalContent: some View {
let mountedWorkspaceIdSet = Set(mountedWorkspaceIds)
let mountedWorkspaces = tabManager.tabs.filter { mountedWorkspaceIdSet.contains($0.id) }
let selectedWorkspaceId = tabManager.selectedTabId
let retiringWorkspaceId = self.retiringWorkspaceId
return ZStack {
ZStack {
ForEach(mountedWorkspaces) { tab in
let isActive = tabManager.selectedTabId == tab.id
WorkspaceContentView(workspace: tab, isTabActive: isActive)
.opacity(isActive ? 1 : 0)
.allowsHitTesting(isActive)
let isSelectedWorkspace = selectedWorkspaceId == tab.id
let isRetiringWorkspace = retiringWorkspaceId == tab.id
let isInputActive = isSelectedWorkspace || isRetiringWorkspace
let isVisible = isSelectedWorkspace || isRetiringWorkspace
WorkspaceContentView(
workspace: tab,
isWorkspaceVisible: isVisible,
isWorkspaceInputActive: isInputActive
)
.opacity(isVisible ? 1 : 0)
.allowsHitTesting(isSelectedWorkspace)
.zIndex(isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0))
}
}
.opacity(sidebarSelectionState.selection == .tabs ? 1 : 0)
@ -590,6 +637,7 @@ struct ContentView: View {
.onAppear {
tabManager.applyWindowBackgroundForSelectedTab()
reconcileMountedWorkspaceIds()
previousSelectedWorkspaceId = tabManager.selectedTabId
if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId {
selectedTabIds = [selectedId]
lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId }
@ -597,7 +645,18 @@ struct ContentView: View {
updateTitlebarText()
}
.onChange(of: tabManager.selectedTabId) { newValue in
#if DEBUG
if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() {
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
dlog(
"ws.view.selectedChange id=\(snapshot.id) dt=\(debugMsText(dtMs)) selected=\(debugShortWorkspaceId(newValue))"
)
} else {
dlog("ws.view.selectedChange id=none selected=\(debugShortWorkspaceId(newValue))")
}
#endif
tabManager.applyWindowBackgroundForSelectedTab()
startWorkspaceHandoffIfNeeded(newSelectedId: newValue)
reconcileMountedWorkspaceIds(selectedId: newValue)
guard let newValue else { return }
if selectedTabIds.count <= 1 {
@ -606,6 +665,22 @@ struct ContentView: View {
}
updateTitlebarText()
}
.onChange(of: tabManager.isWorkspaceCycleHot) { _ in
#if DEBUG
if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() {
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
dlog(
"ws.view.hotChange id=\(snapshot.id) dt=\(debugMsText(dtMs)) hot=\(tabManager.isWorkspaceCycleHot ? 1 : 0)"
)
} else {
dlog("ws.view.hotChange id=none hot=\(tabManager.isWorkspaceCycleHot ? 1 : 0)")
}
#endif
reconcileMountedWorkspaceIds()
}
.onChange(of: retiringWorkspaceId) { _ in
reconcileMountedWorkspaceIds()
}
.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidSetTitle)) { notification in
guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID,
tabId == tabManager.selectedTabId else { return }
@ -618,10 +693,24 @@ struct ContentView: View {
.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidFocusSurface)) { notification in
guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID,
tabId == tabManager.selectedTabId else { return }
completeWorkspaceHandoffIfNeeded(focusedTabId: tabId, reason: "focus")
updateTitlebarText()
}
.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidBecomeFirstResponderSurface)) { notification in
guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID,
tabId == tabManager.selectedTabId else { return }
completeWorkspaceHandoffIfNeeded(focusedTabId: tabId, reason: "first_responder")
}
.onReceive(tabManager.$tabs) { tabs in
let existingIds = Set(tabs.map { $0.id })
if let retiringWorkspaceId, !existingIds.contains(retiringWorkspaceId) {
self.retiringWorkspaceId = nil
workspaceHandoffFallbackTask?.cancel()
workspaceHandoffFallbackTask = nil
}
if let previousSelectedWorkspaceId, !existingIds.contains(previousSelectedWorkspaceId) {
self.previousSelectedWorkspaceId = tabManager.selectedTabId
}
reconcileMountedWorkspaceIds(tabs: tabs)
selectedTabIds = selectedTabIds.filter { existingIds.contains($0) }
if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId {
@ -728,13 +817,44 @@ struct ContentView: View {
private func reconcileMountedWorkspaceIds(tabs: [Workspace]? = nil, selectedId: UUID? = nil) {
let currentTabs = tabs ?? tabManager.tabs
let existing = Set(currentTabs.map { $0.id })
let orderedTabIds = currentTabs.map { $0.id }
let effectiveSelectedId = selectedId ?? tabManager.selectedTabId
let pinnedIds = retiringWorkspaceId.map { Set([ $0 ]) } ?? []
let isCycleHot = tabManager.isWorkspaceCycleHot
let baseMaxMounted = isCycleHot
? WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle
: WorkspaceMountPolicy.maxMountedWorkspaces
let selectedCount = effectiveSelectedId == nil ? 0 : 1
let maxMounted = max(baseMaxMounted, selectedCount + pinnedIds.count)
let previousMountedIds = mountedWorkspaceIds
mountedWorkspaceIds = WorkspaceMountPolicy.nextMountedWorkspaceIds(
current: mountedWorkspaceIds,
selected: effectiveSelectedId,
existing: existing
pinnedIds: pinnedIds,
orderedTabIds: orderedTabIds,
isCycleHot: isCycleHot,
maxMounted: maxMounted
)
#if DEBUG
if mountedWorkspaceIds != previousMountedIds {
let added = mountedWorkspaceIds.filter { !previousMountedIds.contains($0) }
let removed = previousMountedIds.filter { !mountedWorkspaceIds.contains($0) }
if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() {
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
dlog(
"ws.mount.reconcile id=\(snapshot.id) dt=\(debugMsText(dtMs)) hot=\(isCycleHot ? 1 : 0) " +
"selected=\(debugShortWorkspaceId(effectiveSelectedId)) " +
"mounted=\(debugShortWorkspaceIds(mountedWorkspaceIds)) " +
"added=\(debugShortWorkspaceIds(added)) removed=\(debugShortWorkspaceIds(removed))"
)
} else {
dlog(
"ws.mount.reconcile id=none hot=\(isCycleHot ? 1 : 0) selected=\(debugShortWorkspaceId(effectiveSelectedId)) " +
"mounted=\(debugShortWorkspaceIds(mountedWorkspaceIds))"
)
}
}
#endif
}
private func addTab() {
@ -758,6 +878,90 @@ struct ContentView: View {
}
}
}
private func startWorkspaceHandoffIfNeeded(newSelectedId: UUID?) {
let oldSelectedId = previousSelectedWorkspaceId
previousSelectedWorkspaceId = newSelectedId
guard let oldSelectedId, let newSelectedId, oldSelectedId != newSelectedId else {
tabManager.completePendingWorkspaceUnfocus(reason: "no_handoff")
retiringWorkspaceId = nil
workspaceHandoffFallbackTask?.cancel()
workspaceHandoffFallbackTask = nil
return
}
workspaceHandoffGeneration &+= 1
let generation = workspaceHandoffGeneration
retiringWorkspaceId = oldSelectedId
workspaceHandoffFallbackTask?.cancel()
#if DEBUG
if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() {
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
dlog(
"ws.handoff.start id=\(snapshot.id) dt=\(debugMsText(dtMs)) old=\(debugShortWorkspaceId(oldSelectedId)) " +
"new=\(debugShortWorkspaceId(newSelectedId))"
)
} else {
dlog(
"ws.handoff.start id=none old=\(debugShortWorkspaceId(oldSelectedId)) new=\(debugShortWorkspaceId(newSelectedId))"
)
}
#endif
workspaceHandoffFallbackTask = Task { [generation] in
do {
try await Task.sleep(nanoseconds: 150_000_000)
} catch {
return
}
await MainActor.run {
guard workspaceHandoffGeneration == generation else { return }
completeWorkspaceHandoff(reason: "timeout")
}
}
}
private func completeWorkspaceHandoffIfNeeded(focusedTabId: UUID, reason: String) {
guard focusedTabId == tabManager.selectedTabId else { return }
guard retiringWorkspaceId != nil else { return }
completeWorkspaceHandoff(reason: reason)
}
private func completeWorkspaceHandoff(reason: String) {
workspaceHandoffFallbackTask?.cancel()
workspaceHandoffFallbackTask = nil
let retiring = retiringWorkspaceId
retiringWorkspaceId = nil
tabManager.completePendingWorkspaceUnfocus(reason: reason)
#if DEBUG
if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() {
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
dlog(
"ws.handoff.complete id=\(snapshot.id) dt=\(debugMsText(dtMs)) reason=\(reason) retiring=\(debugShortWorkspaceId(retiring))"
)
} else {
dlog("ws.handoff.complete id=none reason=\(reason) retiring=\(debugShortWorkspaceId(retiring))")
}
#endif
}
#if DEBUG
private func debugShortWorkspaceId(_ id: UUID?) -> String {
guard let id else { return "nil" }
return String(id.uuidString.prefix(5))
}
private func debugShortWorkspaceIds(_ ids: [UUID]) -> String {
if ids.isEmpty { return "[]" }
return "[" + ids.map { String($0.uuidString.prefix(5)) }.joined(separator: ",") + "]"
}
private func debugMsText(_ ms: Double) -> String {
String(format: "%.2fms", ms)
}
#endif
}
struct VerticalTabsSidebar: View {

View file

@ -1842,6 +1842,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
)
}
#endif
if let terminalSurface {
NotificationCenter.default.post(
name: .ghosttyDidBecomeFirstResponderSurface,
object: nil,
userInfo: [
GhosttyNotificationKey.tabId: terminalSurface.tabId,
GhosttyNotificationKey.surfaceId: terminalSurface.id,
]
)
}
ghostty_surface_set_focus(surface, true)
// Ghostty only restarts its vsync display link on display-id changes while focused.
@ -2734,6 +2744,10 @@ final class GhosttySurfaceScrollView: NSView {
}
return (presentCounts[surfaceId, default: 0], lastPresentTimes[surfaceId, default: 0], key)
}
var debugSurfaceId: UUID? {
surfaceView.terminalSurface?.id
}
#endif
init(surfaceView: GhosttyNSView) {
@ -2969,8 +2983,17 @@ final class GhosttySurfaceScrollView: NSView {
}
func setVisibleInUI(_ visible: Bool) {
let wasVisible = surfaceView.isVisibleInUI
surfaceView.setVisibleInUI(visible)
isHidden = !visible
#if DEBUG
if wasVisible != visible {
debugLogWorkspaceSwitchTiming(
event: "ws.term.visible",
suffix: "surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") value=\(visible ? 1 : 0)"
)
}
#endif
if !visible {
// If we were focused, yield first responder.
if let window, let fr = window.firstResponder as? NSView,
@ -2983,7 +3006,16 @@ final class GhosttySurfaceScrollView: NSView {
}
func setActive(_ active: Bool) {
let wasActive = isActive
isActive = active
#if DEBUG
if wasActive != active {
debugLogWorkspaceSwitchTiming(
event: "ws.term.active",
suffix: "surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") value=\(active ? 1 : 0)"
)
}
#endif
if active {
applyFirstResponderIfNeeded()
} else if let window,
@ -2993,6 +3025,17 @@ final class GhosttySurfaceScrollView: NSView {
}
}
#if DEBUG
private func debugLogWorkspaceSwitchTiming(event: String, suffix: String) {
guard let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() else {
dlog("\(event) id=none \(suffix)")
return
}
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
dlog("\(event) id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) \(suffix)")
}
#endif
func moveFocus(from previous: GhosttySurfaceScrollView? = nil, delay: TimeInterval? = nil) {
#if DEBUG
dlog("focus.moveFocus to=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil")")
@ -3686,9 +3729,27 @@ struct GhosttyTerminalView: NSViewRepresentable {
func updateNSView(_ nsView: NSView, context: Context) {
let hostedView = terminalSurface.hostedView
let coordinator = context.coordinator
let previousDesiredIsActive = coordinator.desiredIsActive
let previousDesiredIsVisibleInUI = coordinator.desiredIsVisibleInUI
coordinator.desiredIsActive = isActive
coordinator.desiredIsVisibleInUI = isVisibleInUI
coordinator.hostedView = hostedView
#if DEBUG
if previousDesiredIsActive != isActive || previousDesiredIsVisibleInUI != isVisibleInUI {
if let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() {
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
dlog(
"ws.swiftui.update id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) " +
"surface=\(terminalSurface.id.uuidString.prefix(5)) visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0)"
)
} else {
dlog(
"ws.swiftui.update id=none surface=\(terminalSurface.id.uuidString.prefix(5)) " +
"visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0)"
)
}
}
#endif
// Keep the surface lifecycle and handlers updated even if we defer re-parenting.
hostedView.attachSurface(terminalSurface)
@ -3744,6 +3805,19 @@ struct GhosttyTerminalView: NSViewRepresentable {
coordinator.attachGeneration += 1
coordinator.desiredIsActive = false
coordinator.desiredIsVisibleInUI = false
#if DEBUG
if let hostedView = coordinator.hostedView {
if let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() {
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
dlog(
"ws.swiftui.dismantle id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) " +
"surface=\(hostedView.debugSurfaceId?.uuidString.prefix(5) ?? "nil")"
)
} else {
dlog("ws.swiftui.dismantle id=none surface=\(hostedView.debugSurfaceId?.uuidString.prefix(5) ?? "nil")")
}
}
#endif
if let host = nsView as? HostContainerView {
host.onDidMoveToWindow = nil

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

@ -5,7 +5,8 @@ import Bonsplit
/// View that renders a Workspace's content using BonsplitView
struct WorkspaceContentView: View {
@ObservedObject var workspace: Workspace
let isTabActive: Bool
let isWorkspaceVisible: Bool
let isWorkspaceInputActive: Bool
@State private var config = GhosttyConfig.load()
@EnvironmentObject var notificationStore: TerminalNotificationStore
@ -16,7 +17,7 @@ struct WorkspaceContentView: View {
// Inactive workspaces are kept alive in a ZStack (for state preservation) but their
// AppKit-backed views can still intercept drags. Disable drop acceptance for them.
let _ = { workspace.bonsplitController.isInteractive = isTabActive }()
let _ = { workspace.bonsplitController.isInteractive = isWorkspaceInputActive }()
// Wire up file drop handling so bonsplit's PaneDragContainerView can forward
// Finder file drops to the correct terminal panel.
@ -35,9 +36,9 @@ struct WorkspaceContentView: View {
// Content for each tab in bonsplit
let _ = Self.debugPanelLookup(tab: tab, workspace: workspace)
if let panel = workspace.panel(for: tab.id) {
let isFocused = isTabActive && workspace.focusedPanelId == panel.id
let isFocused = isWorkspaceInputActive && workspace.focusedPanelId == panel.id
let isSelectedInPane = workspace.bonsplitController.selectedTab(inPane: paneId)?.id == tab.id
let isVisibleInUI = isTabActive && isSelectedInPane
let isVisibleInUI = isWorkspaceVisible && isSelectedInPane
PanelContentView(
panel: panel,
isFocused: isFocused,
@ -50,12 +51,12 @@ struct WorkspaceContentView: View {
// Keep bonsplit focus in sync with the AppKit first responder for the
// active workspace. This prevents divergence between the blue focused-tab
// indicator and where keyboard input/flash-focus actually lands.
guard isTabActive else { return }
guard isWorkspaceInputActive else { return }
guard workspace.panels[panel.id] != nil else { return }
workspace.focusPanel(panel.id)
},
onRequestPanelFocus: {
guard isTabActive else { return }
guard isWorkspaceInputActive else { return }
guard workspace.panels[panel.id] != nil else { return }
workspace.focusPanel(panel.id)
},

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