From 7aa80b9cdc3e14d488e3375c3d3c3742c65ec937 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:17:53 -0800 Subject: [PATCH] Stabilize rapid workspace switching handoff --- .../CmuxWebViewKeyEquivalentTests.swift | 113 +++++++- Sources/AppDelegate.swift | 12 + Sources/ContentView.swift | 220 +++++++++++++++- Sources/GhosttyTerminalView.swift | 74 ++++++ Sources/TabManager.swift | 241 +++++++++++++++++- Sources/WorkspaceContentView.swift | 13 +- scripts/test-unit.sh | 21 ++ 7 files changed, 664 insertions(+), 30 deletions(-) create mode 100755 scripts/test-unit.sh diff --git a/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift b/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift index daf5706a..3bb81093 100644 --- a/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift +++ b/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift @@ -426,6 +426,35 @@ final class WorkspaceReorderTests: XCTestCase { } } +@MainActor +final class TabManagerPendingUnfocusPolicyTests: XCTestCase { + func testDoesNotUnfocusWhenPendingTabIsCurrentlySelected() { + let tabId = UUID() + + XCTAssertFalse( + TabManager.shouldUnfocusPendingWorkspace( + pendingTabId: tabId, + selectedTabId: tabId + ) + ) + } + + func testUnfocusesWhenPendingTabIsNotSelected() { + XCTAssertTrue( + TabManager.shouldUnfocusPendingWorkspace( + pendingTabId: UUID(), + selectedTabId: UUID() + ) + ) + XCTAssertTrue( + TabManager.shouldUnfocusPendingWorkspace( + pendingTabId: UUID(), + selectedTabId: nil + ) + ) + } +} + @MainActor final class TabManagerSurfaceCreationTests: XCTestCase { func testNewSurfaceFocusesCreatedSurface() { @@ -1692,12 +1721,15 @@ final class WorkspaceMountPolicyTests: XCTestCase { func testDefaultPolicyMountsOnlySelectedWorkspace() { let a = UUID() let b = UUID() - let existing: Set = [a, b] + let orderedTabIds: [UUID] = [a, b] let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( current: [a], selected: b, - existing: existing + pinnedIds: [], + orderedTabIds: orderedTabIds, + isCycleHot: false, + maxMounted: WorkspaceMountPolicy.maxMountedWorkspaces ) XCTAssertEqual(next, [b]) @@ -1707,12 +1739,14 @@ final class WorkspaceMountPolicyTests: XCTestCase { let a = UUID() let b = UUID() let c = UUID() - let existing: Set = [a, b, c] + let orderedTabIds: [UUID] = [a, b, c] let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( current: [a, b, c], selected: c, - existing: existing, + pinnedIds: [], + orderedTabIds: orderedTabIds, + isCycleHot: false, maxMounted: 2 ) @@ -1726,7 +1760,9 @@ final class WorkspaceMountPolicyTests: XCTestCase { let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( current: [b, a], selected: nil, - existing: [a], + pinnedIds: [], + orderedTabIds: [a], + isCycleHot: false, maxMounted: 2 ) @@ -1736,12 +1772,14 @@ final class WorkspaceMountPolicyTests: XCTestCase { func testSelectedWorkspaceIsInsertedWhenAbsentFromCurrentCache() { let a = UUID() let b = UUID() - let existing: Set = [a, b] + let orderedTabIds: [UUID] = [a, b] let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( current: [a], selected: b, - existing: existing, + pinnedIds: [], + orderedTabIds: orderedTabIds, + isCycleHot: false, maxMounted: 2 ) @@ -1751,17 +1789,74 @@ final class WorkspaceMountPolicyTests: XCTestCase { func testMaxMountedIsClampedToAtLeastOne() { let a = UUID() let b = UUID() - let existing: Set = [a, b] + let orderedTabIds: [UUID] = [a, b] let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( current: [a, b], selected: nil, - existing: existing, + pinnedIds: [], + orderedTabIds: orderedTabIds, + isCycleHot: false, maxMounted: 0 ) XCTAssertEqual(next, [a]) } + + func testCycleHotModeWarmsSelectedAndImmediateNeighbors() { + let a = UUID() + let b = UUID() + let c = UUID() + let d = UUID() + let orderedTabIds: [UUID] = [a, b, c, d] + + let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( + current: [a], + selected: c, + pinnedIds: [], + orderedTabIds: orderedTabIds, + isCycleHot: true, + maxMounted: WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle + ) + + XCTAssertEqual(next, [c, b, d]) + } + + func testCycleHotModeRespectsMaxMountedLimit() { + let a = UUID() + let b = UUID() + let c = UUID() + let orderedTabIds: [UUID] = [a, b, c] + + let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( + current: [a, b, c], + selected: b, + pinnedIds: [], + orderedTabIds: orderedTabIds, + isCycleHot: true, + maxMounted: 2 + ) + + XCTAssertEqual(next, [b, a]) + } + + func testPinnedIdsAreRetainedAcrossReconcile() { + let a = UUID() + let b = UUID() + let c = UUID() + let orderedTabIds: [UUID] = [a, b, c] + + let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( + current: [a], + selected: c, + pinnedIds: [a], + orderedTabIds: orderedTabIds, + isCycleHot: false, + maxMounted: 2 + ) + + XCTAssertEqual(next, [c, a]) + } } @MainActor diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 0b594126..66daff9c 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1751,11 +1751,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Workspace navigation: Cmd+Ctrl+] / Cmd+Ctrl+[ if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .nextSidebarTab)) { +#if DEBUG + let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "ws.shortcut dir=next repeat=\(event.isARepeat ? 1 : 0) keyCode=\(event.keyCode) selected=\(selected)" + ) +#endif tabManager?.selectNextTab() return true } if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .prevSidebarTab)) { +#if DEBUG + let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "ws.shortcut dir=prev repeat=\(event.isARepeat ? 1 : 0) keyCode=\(event.keyCode) selected=\(selected)" + ) +#endif tabManager?.selectPreviousTab() return true } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 584d8cab..f2d7bf6b 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -274,13 +274,17 @@ var fileDropOverlayKey: UInt8 = 0 enum WorkspaceMountPolicy { // Keep only the selected workspace mounted to minimize layer-tree traversal. static let maxMountedWorkspaces = 1 + static let maxMountedWorkspacesDuringCycle = 3 static func nextMountedWorkspaceIds( current: [UUID], selected: UUID?, - existing: Set, - maxMounted: Int = maxMountedWorkspaces + pinnedIds: Set, + orderedTabIds: [UUID], + isCycleHot: Bool, + maxMounted: Int ) -> [UUID] { + let existing = Set(orderedTabIds) let clampedMax = max(1, maxMounted) var ordered = current.filter { existing.contains($0) } @@ -289,12 +293,41 @@ enum WorkspaceMountPolicy { ordered.insert(selected, at: 0) } + let prioritizedPinnedIds = pinnedIds.filter { existing.contains($0) && $0 != selected } + for pinnedId in prioritizedPinnedIds.reversed() { + ordered.removeAll { $0 == pinnedId } + ordered.insert(pinnedId, at: 0) + } + + if isCycleHot, let selected { + let warmIds = cycleWarmIds(selected: selected, orderedTabIds: orderedTabIds) + for id in warmIds.reversed() { + ordered.removeAll { $0 == id } + ordered.insert(id, at: 0) + } + } + if ordered.count > clampedMax { ordered.removeSubrange(clampedMax...) } return ordered } + + private static func cycleWarmIds(selected: UUID, orderedTabIds: [UUID]) -> [UUID] { + guard let selectedIndex = orderedTabIds.firstIndex(of: selected) else { + return [selected] + } + + var ids: [UUID] = [selected] + if selectedIndex > 0 { + ids.append(orderedTabIds[selectedIndex - 1]) + } + if selectedIndex + 1 < orderedTabIds.count { + ids.append(orderedTabIds[selectedIndex + 1]) + } + return ids + } } /// Installs a FileDropOverlayView on the window's theme frame for Finder file drag support. @@ -343,6 +376,10 @@ struct ContentView: View { @State private var isFullScreen: Bool = false @State private var observedWindow: NSWindow? @StateObject private var fullscreenControlsViewModel = TitlebarControlsViewModel() + @State private var previousSelectedWorkspaceId: UUID? + @State private var retiringWorkspaceId: UUID? + @State private var workspaceHandoffGeneration: UInt64 = 0 + @State private var workspaceHandoffFallbackTask: Task? private var sidebarView: some View { VerticalTabsSidebar( @@ -418,14 +455,24 @@ struct ContentView: View { private var terminalContent: some View { let mountedWorkspaceIdSet = Set(mountedWorkspaceIds) let mountedWorkspaces = tabManager.tabs.filter { mountedWorkspaceIdSet.contains($0.id) } + let selectedWorkspaceId = tabManager.selectedTabId + let retiringWorkspaceId = self.retiringWorkspaceId return ZStack { ZStack { ForEach(mountedWorkspaces) { tab in - let isActive = tabManager.selectedTabId == tab.id - WorkspaceContentView(workspace: tab, isTabActive: isActive) - .opacity(isActive ? 1 : 0) - .allowsHitTesting(isActive) + let isSelectedWorkspace = selectedWorkspaceId == tab.id + let isRetiringWorkspace = retiringWorkspaceId == tab.id + let isInputActive = isSelectedWorkspace || isRetiringWorkspace + let isVisible = isSelectedWorkspace || isRetiringWorkspace + WorkspaceContentView( + workspace: tab, + isWorkspaceVisible: isVisible, + isWorkspaceInputActive: isInputActive + ) + .opacity(isVisible ? 1 : 0) + .allowsHitTesting(isSelectedWorkspace) + .zIndex(isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0)) } } .opacity(sidebarSelectionState.selection == .tabs ? 1 : 0) @@ -590,6 +637,7 @@ struct ContentView: View { .onAppear { tabManager.applyWindowBackgroundForSelectedTab() reconcileMountedWorkspaceIds() + previousSelectedWorkspaceId = tabManager.selectedTabId if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId { selectedTabIds = [selectedId] lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } @@ -597,7 +645,18 @@ struct ContentView: View { updateTitlebarText() } .onChange(of: tabManager.selectedTabId) { newValue in +#if DEBUG + if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { + let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 + dlog( + "ws.view.selectedChange id=\(snapshot.id) dt=\(debugMsText(dtMs)) selected=\(debugShortWorkspaceId(newValue))" + ) + } else { + dlog("ws.view.selectedChange id=none selected=\(debugShortWorkspaceId(newValue))") + } +#endif tabManager.applyWindowBackgroundForSelectedTab() + startWorkspaceHandoffIfNeeded(newSelectedId: newValue) reconcileMountedWorkspaceIds(selectedId: newValue) guard let newValue else { return } if selectedTabIds.count <= 1 { @@ -606,6 +665,22 @@ struct ContentView: View { } updateTitlebarText() } + .onChange(of: tabManager.isWorkspaceCycleHot) { _ in +#if DEBUG + if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { + let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 + dlog( + "ws.view.hotChange id=\(snapshot.id) dt=\(debugMsText(dtMs)) hot=\(tabManager.isWorkspaceCycleHot ? 1 : 0)" + ) + } else { + dlog("ws.view.hotChange id=none hot=\(tabManager.isWorkspaceCycleHot ? 1 : 0)") + } +#endif + reconcileMountedWorkspaceIds() + } + .onChange(of: retiringWorkspaceId) { _ in + reconcileMountedWorkspaceIds() + } .onReceive(NotificationCenter.default.publisher(for: .ghosttyDidSetTitle)) { notification in guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID, tabId == tabManager.selectedTabId else { return } @@ -618,10 +693,24 @@ struct ContentView: View { .onReceive(NotificationCenter.default.publisher(for: .ghosttyDidFocusSurface)) { notification in guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID, tabId == tabManager.selectedTabId else { return } + completeWorkspaceHandoffIfNeeded(focusedTabId: tabId, reason: "focus") updateTitlebarText() } + .onReceive(NotificationCenter.default.publisher(for: .ghosttyDidBecomeFirstResponderSurface)) { notification in + guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID, + tabId == tabManager.selectedTabId else { return } + completeWorkspaceHandoffIfNeeded(focusedTabId: tabId, reason: "first_responder") + } .onReceive(tabManager.$tabs) { tabs in let existingIds = Set(tabs.map { $0.id }) + if let retiringWorkspaceId, !existingIds.contains(retiringWorkspaceId) { + self.retiringWorkspaceId = nil + workspaceHandoffFallbackTask?.cancel() + workspaceHandoffFallbackTask = nil + } + if let previousSelectedWorkspaceId, !existingIds.contains(previousSelectedWorkspaceId) { + self.previousSelectedWorkspaceId = tabManager.selectedTabId + } reconcileMountedWorkspaceIds(tabs: tabs) selectedTabIds = selectedTabIds.filter { existingIds.contains($0) } if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId { @@ -728,13 +817,44 @@ struct ContentView: View { private func reconcileMountedWorkspaceIds(tabs: [Workspace]? = nil, selectedId: UUID? = nil) { let currentTabs = tabs ?? tabManager.tabs - let existing = Set(currentTabs.map { $0.id }) + let orderedTabIds = currentTabs.map { $0.id } let effectiveSelectedId = selectedId ?? tabManager.selectedTabId + let pinnedIds = retiringWorkspaceId.map { Set([ $0 ]) } ?? [] + let isCycleHot = tabManager.isWorkspaceCycleHot + let baseMaxMounted = isCycleHot + ? WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle + : WorkspaceMountPolicy.maxMountedWorkspaces + let selectedCount = effectiveSelectedId == nil ? 0 : 1 + let maxMounted = max(baseMaxMounted, selectedCount + pinnedIds.count) + let previousMountedIds = mountedWorkspaceIds mountedWorkspaceIds = WorkspaceMountPolicy.nextMountedWorkspaceIds( current: mountedWorkspaceIds, selected: effectiveSelectedId, - existing: existing + pinnedIds: pinnedIds, + orderedTabIds: orderedTabIds, + isCycleHot: isCycleHot, + maxMounted: maxMounted ) +#if DEBUG + if mountedWorkspaceIds != previousMountedIds { + let added = mountedWorkspaceIds.filter { !previousMountedIds.contains($0) } + let removed = previousMountedIds.filter { !mountedWorkspaceIds.contains($0) } + if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { + let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 + dlog( + "ws.mount.reconcile id=\(snapshot.id) dt=\(debugMsText(dtMs)) hot=\(isCycleHot ? 1 : 0) " + + "selected=\(debugShortWorkspaceId(effectiveSelectedId)) " + + "mounted=\(debugShortWorkspaceIds(mountedWorkspaceIds)) " + + "added=\(debugShortWorkspaceIds(added)) removed=\(debugShortWorkspaceIds(removed))" + ) + } else { + dlog( + "ws.mount.reconcile id=none hot=\(isCycleHot ? 1 : 0) selected=\(debugShortWorkspaceId(effectiveSelectedId)) " + + "mounted=\(debugShortWorkspaceIds(mountedWorkspaceIds))" + ) + } + } +#endif } private func addTab() { @@ -758,6 +878,90 @@ struct ContentView: View { } } } + + private func startWorkspaceHandoffIfNeeded(newSelectedId: UUID?) { + let oldSelectedId = previousSelectedWorkspaceId + previousSelectedWorkspaceId = newSelectedId + + guard let oldSelectedId, let newSelectedId, oldSelectedId != newSelectedId else { + tabManager.completePendingWorkspaceUnfocus(reason: "no_handoff") + retiringWorkspaceId = nil + workspaceHandoffFallbackTask?.cancel() + workspaceHandoffFallbackTask = nil + return + } + + workspaceHandoffGeneration &+= 1 + let generation = workspaceHandoffGeneration + retiringWorkspaceId = oldSelectedId + workspaceHandoffFallbackTask?.cancel() + +#if DEBUG + if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { + let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 + dlog( + "ws.handoff.start id=\(snapshot.id) dt=\(debugMsText(dtMs)) old=\(debugShortWorkspaceId(oldSelectedId)) " + + "new=\(debugShortWorkspaceId(newSelectedId))" + ) + } else { + dlog( + "ws.handoff.start id=none old=\(debugShortWorkspaceId(oldSelectedId)) new=\(debugShortWorkspaceId(newSelectedId))" + ) + } +#endif + + workspaceHandoffFallbackTask = Task { [generation] in + do { + try await Task.sleep(nanoseconds: 150_000_000) + } catch { + return + } + await MainActor.run { + guard workspaceHandoffGeneration == generation else { return } + completeWorkspaceHandoff(reason: "timeout") + } + } + } + + private func completeWorkspaceHandoffIfNeeded(focusedTabId: UUID, reason: String) { + guard focusedTabId == tabManager.selectedTabId else { return } + guard retiringWorkspaceId != nil else { return } + completeWorkspaceHandoff(reason: reason) + } + + private func completeWorkspaceHandoff(reason: String) { + workspaceHandoffFallbackTask?.cancel() + workspaceHandoffFallbackTask = nil + let retiring = retiringWorkspaceId + retiringWorkspaceId = nil + tabManager.completePendingWorkspaceUnfocus(reason: reason) +#if DEBUG + if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { + let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 + dlog( + "ws.handoff.complete id=\(snapshot.id) dt=\(debugMsText(dtMs)) reason=\(reason) retiring=\(debugShortWorkspaceId(retiring))" + ) + } else { + dlog("ws.handoff.complete id=none reason=\(reason) retiring=\(debugShortWorkspaceId(retiring))") + } +#endif + } + +#if DEBUG + private func debugShortWorkspaceId(_ id: UUID?) -> String { + guard let id else { return "nil" } + return String(id.uuidString.prefix(5)) + } + + private func debugShortWorkspaceIds(_ ids: [UUID]) -> String { + if ids.isEmpty { return "[]" } + return "[" + ids.map { String($0.uuidString.prefix(5)) }.joined(separator: ",") + "]" + } + + private func debugMsText(_ ms: Double) -> String { + String(format: "%.2fms", ms) + } +#endif } struct VerticalTabsSidebar: View { diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index a0d4bc88..68572571 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1842,6 +1842,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { ) } #endif + if let terminalSurface { + NotificationCenter.default.post( + name: .ghosttyDidBecomeFirstResponderSurface, + object: nil, + userInfo: [ + GhosttyNotificationKey.tabId: terminalSurface.tabId, + GhosttyNotificationKey.surfaceId: terminalSurface.id, + ] + ) + } ghostty_surface_set_focus(surface, true) // Ghostty only restarts its vsync display link on display-id changes while focused. @@ -2734,6 +2744,10 @@ final class GhosttySurfaceScrollView: NSView { } return (presentCounts[surfaceId, default: 0], lastPresentTimes[surfaceId, default: 0], key) } + + var debugSurfaceId: UUID? { + surfaceView.terminalSurface?.id + } #endif init(surfaceView: GhosttyNSView) { @@ -2969,8 +2983,17 @@ final class GhosttySurfaceScrollView: NSView { } func setVisibleInUI(_ visible: Bool) { + let wasVisible = surfaceView.isVisibleInUI surfaceView.setVisibleInUI(visible) isHidden = !visible +#if DEBUG + if wasVisible != visible { + debugLogWorkspaceSwitchTiming( + event: "ws.term.visible", + suffix: "surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") value=\(visible ? 1 : 0)" + ) + } +#endif if !visible { // If we were focused, yield first responder. if let window, let fr = window.firstResponder as? NSView, @@ -2983,7 +3006,16 @@ final class GhosttySurfaceScrollView: NSView { } func setActive(_ active: Bool) { + let wasActive = isActive isActive = active +#if DEBUG + if wasActive != active { + debugLogWorkspaceSwitchTiming( + event: "ws.term.active", + suffix: "surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") value=\(active ? 1 : 0)" + ) + } +#endif if active { applyFirstResponderIfNeeded() } else if let window, @@ -2993,6 +3025,17 @@ final class GhosttySurfaceScrollView: NSView { } } +#if DEBUG + private func debugLogWorkspaceSwitchTiming(event: String, suffix: String) { + guard let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() else { + dlog("\(event) id=none \(suffix)") + return + } + let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 + dlog("\(event) id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) \(suffix)") + } +#endif + func moveFocus(from previous: GhosttySurfaceScrollView? = nil, delay: TimeInterval? = nil) { #if DEBUG dlog("focus.moveFocus to=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil")") @@ -3686,9 +3729,27 @@ struct GhosttyTerminalView: NSViewRepresentable { func updateNSView(_ nsView: NSView, context: Context) { let hostedView = terminalSurface.hostedView let coordinator = context.coordinator + let previousDesiredIsActive = coordinator.desiredIsActive + let previousDesiredIsVisibleInUI = coordinator.desiredIsVisibleInUI coordinator.desiredIsActive = isActive coordinator.desiredIsVisibleInUI = isVisibleInUI coordinator.hostedView = hostedView +#if DEBUG + if previousDesiredIsActive != isActive || previousDesiredIsVisibleInUI != isVisibleInUI { + if let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() { + let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 + dlog( + "ws.swiftui.update id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) " + + "surface=\(terminalSurface.id.uuidString.prefix(5)) visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0)" + ) + } else { + dlog( + "ws.swiftui.update id=none surface=\(terminalSurface.id.uuidString.prefix(5)) " + + "visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0)" + ) + } + } +#endif // Keep the surface lifecycle and handlers updated even if we defer re-parenting. hostedView.attachSurface(terminalSurface) @@ -3744,6 +3805,19 @@ struct GhosttyTerminalView: NSViewRepresentable { coordinator.attachGeneration += 1 coordinator.desiredIsActive = false coordinator.desiredIsVisibleInUI = false +#if DEBUG + if let hostedView = coordinator.hostedView { + if let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() { + let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 + dlog( + "ws.swiftui.dismantle id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) " + + "surface=\(hostedView.debugSurfaceId?.uuidString.prefix(5) ?? "nil")" + ) + } else { + dlog("ws.swiftui.dismantle id=none surface=\(hostedView.debugSurfaceId?.uuidString.prefix(5) ?? "nil")") + } + } +#endif if let host = nsView as? HostContainerView { host.onDidMoveToWindow = nil diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index ea7ee068..e33fb425 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -221,6 +221,7 @@ fileprivate func cmuxVsyncIOSurfaceTimelineCallback( @MainActor class TabManager: ObservableObject { @Published var tabs: [Workspace] = [] + @Published private(set) var isWorkspaceCycleHot: Bool = false @Published var selectedTabId: UUID? { didSet { guard selectedTabId != oldValue else { return } @@ -232,12 +233,34 @@ class TabManager: ObservableObject { if !isNavigatingHistory, let selectedTabId { recordTabInHistory(selectedTabId) } +#if DEBUG + let switchId = debugWorkspaceSwitchId + let switchDtMs = debugWorkspaceSwitchStartTime > 0 + ? (CACurrentMediaTime() - debugWorkspaceSwitchStartTime) * 1000 + : 0 + dlog( + "ws.select.didSet id=\(switchId) from=\(Self.debugShortWorkspaceId(previousTabId)) " + + "to=\(Self.debugShortWorkspaceId(selectedTabId)) dt=\(Self.debugMsText(switchDtMs))" + ) +#endif + selectionSideEffectsGeneration &+= 1 + let generation = selectionSideEffectsGeneration DispatchQueue.main.async { [weak self] in - self?.focusSelectedTabPanel(previousTabId: previousTabId) - self?.updateWindowTitleForSelectedTab() - if let selectedTabId = self?.selectedTabId { - self?.markFocusedPanelReadIfActive(tabId: selectedTabId) + guard let self, self.selectionSideEffectsGeneration == generation else { return } + self.focusSelectedTabPanel(previousTabId: previousTabId) + self.updateWindowTitleForSelectedTab() + if let selectedTabId = self.selectedTabId { + self.markFocusedPanelReadIfActive(tabId: selectedTabId) } +#if DEBUG + let dtMs = self.debugWorkspaceSwitchStartTime > 0 + ? (CACurrentMediaTime() - self.debugWorkspaceSwitchStartTime) * 1000 + : 0 + dlog( + "ws.select.asyncDone id=\(self.debugWorkspaceSwitchId) dt=\(Self.debugMsText(dtMs)) " + + "selected=\(Self.debugShortWorkspaceId(self.selectedTabId))" + ) +#endif } } } @@ -250,6 +273,15 @@ class TabManager: ObservableObject { private var historyIndex: Int = -1 private var isNavigatingHistory = false private let maxHistorySize = 50 + private var selectionSideEffectsGeneration: UInt64 = 0 + private var workspaceCycleGeneration: UInt64 = 0 + private var workspaceCycleCooldownTask: Task? + private var pendingWorkspaceUnfocusTarget: (tabId: UUID, panelId: UUID)? +#if DEBUG + private var debugWorkspaceSwitchCounter: UInt64 = 0 + private var debugWorkspaceSwitchId: UInt64 = 0 + private var debugWorkspaceSwitchStartTime: CFTimeInterval = 0 +#endif #if DEBUG private var didSetupSplitCloseRightUITest = false @@ -291,6 +323,10 @@ class TabManager: ObservableObject { #endif } + deinit { + workspaceCycleCooldownTask?.cancel() + } + var selectedWorkspace: Workspace? { guard let selectedTabId else { return nil } return tabs.first(where: { $0.id == selectedTabId }) @@ -814,12 +850,15 @@ class TabManager: ObservableObject { guard let panelId = tab.focusedPanelId, let panel = tab.panels[panelId] else { return } - // Unfocus previous tab's panel + // Defer unfocusing the previous workspace's panel until ContentView confirms handoff + // completion (new workspace has focus or timeout fallback), to avoid a visible freeze gap. if let previousTabId, let previousTab = tabs.first(where: { $0.id == previousTabId }), let previousPanelId = previousTab.focusedPanelId, - let previousPanel = previousTab.panels[previousPanelId] { - previousPanel.unfocus() + previousTab.panels[previousPanelId] != nil { + replacePendingWorkspaceUnfocusTarget( + with: (tabId: previousTabId, panelId: previousPanelId) + ) } panel.focus() @@ -830,6 +869,94 @@ class TabManager: ObservableObject { } } + func completePendingWorkspaceUnfocus(reason: String) { + guard let pending = pendingWorkspaceUnfocusTarget else { return } + // If this tab became selected again before handoff completion, drop the stale + // pending entry so it cannot be flushed later and deactivate the selected workspace. + guard Self.shouldUnfocusPendingWorkspace( + pendingTabId: pending.tabId, + selectedTabId: selectedTabId + ) else { + pendingWorkspaceUnfocusTarget = nil +#if DEBUG + dlog( + "ws.unfocus.drop tab=\(Self.debugShortWorkspaceId(pending.tabId)) panel=\(String(pending.panelId.uuidString.prefix(5))) reason=selected_again" + ) +#endif + return + } + pendingWorkspaceUnfocusTarget = nil + unfocusWorkspacePanel(tabId: pending.tabId, panelId: pending.panelId) +#if DEBUG + if let snapshot = debugCurrentWorkspaceSwitchSnapshot() { + let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 + dlog( + "ws.unfocus.complete id=\(snapshot.id) dt=\(Self.debugMsText(dtMs)) " + + "tab=\(Self.debugShortWorkspaceId(pending.tabId)) panel=\(String(pending.panelId.uuidString.prefix(5))) reason=\(reason)" + ) + } else { + dlog( + "ws.unfocus.complete id=none tab=\(Self.debugShortWorkspaceId(pending.tabId)) " + + "panel=\(String(pending.panelId.uuidString.prefix(5))) reason=\(reason)" + ) + } +#endif + } + + private func replacePendingWorkspaceUnfocusTarget(with next: (tabId: UUID, panelId: UUID)) { + if let current = pendingWorkspaceUnfocusTarget, + current.tabId == next.tabId, + current.panelId == next.panelId { + return + } + + if let current = pendingWorkspaceUnfocusTarget { + // Never unfocus the currently selected workspace when replacing stale pending state. + if Self.shouldUnfocusPendingWorkspace( + pendingTabId: current.tabId, + selectedTabId: selectedTabId + ) { + unfocusWorkspacePanel(tabId: current.tabId, panelId: current.panelId) +#if DEBUG + dlog( + "ws.unfocus.flush tab=\(Self.debugShortWorkspaceId(current.tabId)) panel=\(String(current.panelId.uuidString.prefix(5))) reason=replaced" + ) +#endif + } else { +#if DEBUG + dlog( + "ws.unfocus.drop tab=\(Self.debugShortWorkspaceId(current.tabId)) panel=\(String(current.panelId.uuidString.prefix(5))) reason=replaced_selected" + ) +#endif + } + } + + pendingWorkspaceUnfocusTarget = next +#if DEBUG + if let snapshot = debugCurrentWorkspaceSwitchSnapshot() { + let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 + dlog( + "ws.unfocus.defer id=\(snapshot.id) dt=\(Self.debugMsText(dtMs)) " + + "tab=\(Self.debugShortWorkspaceId(next.tabId)) panel=\(String(next.panelId.uuidString.prefix(5)))" + ) + } else { + dlog( + "ws.unfocus.defer id=none tab=\(Self.debugShortWorkspaceId(next.tabId)) panel=\(String(next.panelId.uuidString.prefix(5)))" + ) + } +#endif + } + + private func unfocusWorkspacePanel(tabId: UUID, panelId: UUID) { + guard let tab = tabs.first(where: { $0.id == tabId }), + let panel = tab.panels[panelId] else { return } + panel.unfocus() + } + + static func shouldUnfocusPendingWorkspace(pendingTabId: UUID, selectedTabId: UUID?) -> Bool { + selectedTabId != pendingTabId + } + private func markFocusedPanelReadIfActive(tabId: UUID) { let shouldSuppressFlash = suppressFocusFlash suppressFocusFlash = false @@ -959,6 +1086,17 @@ class TabManager: ObservableObject { guard let currentId = selectedTabId, let currentIndex = tabs.firstIndex(where: { $0.id == currentId }) else { return } let nextIndex = (currentIndex + 1) % tabs.count +#if DEBUG + let nextId = tabs[nextIndex].id + debugWorkspaceSwitchCounter &+= 1 + debugWorkspaceSwitchId = debugWorkspaceSwitchCounter + debugWorkspaceSwitchStartTime = CACurrentMediaTime() + dlog( + "ws.switch.begin id=\(debugWorkspaceSwitchId) dir=next from=\(Self.debugShortWorkspaceId(currentId)) " + + "to=\(Self.debugShortWorkspaceId(nextId)) hot=\(isWorkspaceCycleHot ? 1 : 0) tabs=\(tabs.count)" + ) +#endif + activateWorkspaceCycleHotWindow() selectedTabId = tabs[nextIndex].id } @@ -966,9 +1104,97 @@ class TabManager: ObservableObject { guard let currentId = selectedTabId, let currentIndex = tabs.firstIndex(where: { $0.id == currentId }) else { return } let prevIndex = (currentIndex - 1 + tabs.count) % tabs.count +#if DEBUG + let prevId = tabs[prevIndex].id + debugWorkspaceSwitchCounter &+= 1 + debugWorkspaceSwitchId = debugWorkspaceSwitchCounter + debugWorkspaceSwitchStartTime = CACurrentMediaTime() + dlog( + "ws.switch.begin id=\(debugWorkspaceSwitchId) dir=prev from=\(Self.debugShortWorkspaceId(currentId)) " + + "to=\(Self.debugShortWorkspaceId(prevId)) hot=\(isWorkspaceCycleHot ? 1 : 0) tabs=\(tabs.count)" + ) +#endif + activateWorkspaceCycleHotWindow() selectedTabId = tabs[prevIndex].id } + private func activateWorkspaceCycleHotWindow() { + workspaceCycleGeneration &+= 1 + let generation = workspaceCycleGeneration +#if DEBUG + let switchId = debugWorkspaceSwitchId + let switchDtMs = debugWorkspaceSwitchStartTime > 0 + ? (CACurrentMediaTime() - debugWorkspaceSwitchStartTime) * 1000 + : 0 +#endif + if !isWorkspaceCycleHot { + isWorkspaceCycleHot = true +#if DEBUG + dlog( + "ws.hot.on id=\(switchId) gen=\(generation) dt=\(Self.debugMsText(switchDtMs))" + ) +#endif + } + + let hadPendingCooldown = workspaceCycleCooldownTask != nil + workspaceCycleCooldownTask?.cancel() +#if DEBUG + if hadPendingCooldown { + dlog( + "ws.hot.cancelPrev id=\(switchId) gen=\(generation) dt=\(Self.debugMsText(switchDtMs))" + ) + } +#endif + workspaceCycleCooldownTask = Task { [weak self, generation] in + do { + try await Task.sleep(nanoseconds: 220_000_000) + } catch { +#if DEBUG + await MainActor.run { + guard let self else { return } + let dtMs = self.debugWorkspaceSwitchStartTime > 0 + ? (CACurrentMediaTime() - self.debugWorkspaceSwitchStartTime) * 1000 + : 0 + dlog( + "ws.hot.cooldownCanceled id=\(self.debugWorkspaceSwitchId) gen=\(generation) dt=\(Self.debugMsText(dtMs))" + ) + } +#endif + return + } + await MainActor.run { + guard let self else { return } + guard self.workspaceCycleGeneration == generation else { return } +#if DEBUG + let dtMs = self.debugWorkspaceSwitchStartTime > 0 + ? (CACurrentMediaTime() - self.debugWorkspaceSwitchStartTime) * 1000 + : 0 + dlog( + "ws.hot.off id=\(self.debugWorkspaceSwitchId) gen=\(generation) dt=\(Self.debugMsText(dtMs))" + ) +#endif + self.isWorkspaceCycleHot = false + self.workspaceCycleCooldownTask = nil + } + } + } + +#if DEBUG + func debugCurrentWorkspaceSwitchSnapshot() -> (id: UInt64, startedAt: CFTimeInterval)? { + guard debugWorkspaceSwitchId > 0, debugWorkspaceSwitchStartTime > 0 else { return nil } + return (debugWorkspaceSwitchId, debugWorkspaceSwitchStartTime) + } + + private static func debugShortWorkspaceId(_ id: UUID?) -> String { + guard let id else { return "nil" } + return String(id.uuidString.prefix(5)) + } + + private static func debugMsText(_ ms: Double) -> String { + String(format: "%.2fms", ms) + } +#endif + func selectTab(at index: Int) { guard index >= 0 && index < tabs.count else { return } selectedTabId = tabs[index].id @@ -2222,6 +2448,7 @@ extension Notification.Name { static let ghosttyDidSetTitle = Notification.Name("ghosttyDidSetTitle") static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab") static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface") + static let ghosttyDidBecomeFirstResponderSurface = Notification.Name("ghosttyDidBecomeFirstResponderSurface") static let browserFocusAddressBar = Notification.Name("browserFocusAddressBar") static let browserMoveOmnibarSelection = Notification.Name("browserMoveOmnibarSelection") static let browserDidExitAddressBar = Notification.Name("browserDidExitAddressBar") diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index bd2f6ccf..92fc0a42 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -5,7 +5,8 @@ import Bonsplit /// View that renders a Workspace's content using BonsplitView struct WorkspaceContentView: View { @ObservedObject var workspace: Workspace - let isTabActive: Bool + let isWorkspaceVisible: Bool + let isWorkspaceInputActive: Bool @State private var config = GhosttyConfig.load() @EnvironmentObject var notificationStore: TerminalNotificationStore @@ -16,7 +17,7 @@ struct WorkspaceContentView: View { // Inactive workspaces are kept alive in a ZStack (for state preservation) but their // AppKit-backed views can still intercept drags. Disable drop acceptance for them. - let _ = { workspace.bonsplitController.isInteractive = isTabActive }() + let _ = { workspace.bonsplitController.isInteractive = isWorkspaceInputActive }() // Wire up file drop handling so bonsplit's PaneDragContainerView can forward // Finder file drops to the correct terminal panel. @@ -35,9 +36,9 @@ struct WorkspaceContentView: View { // Content for each tab in bonsplit let _ = Self.debugPanelLookup(tab: tab, workspace: workspace) if let panel = workspace.panel(for: tab.id) { - let isFocused = isTabActive && workspace.focusedPanelId == panel.id + let isFocused = isWorkspaceInputActive && workspace.focusedPanelId == panel.id let isSelectedInPane = workspace.bonsplitController.selectedTab(inPane: paneId)?.id == tab.id - let isVisibleInUI = isTabActive && isSelectedInPane + let isVisibleInUI = isWorkspaceVisible && isSelectedInPane PanelContentView( panel: panel, isFocused: isFocused, @@ -50,12 +51,12 @@ struct WorkspaceContentView: View { // Keep bonsplit focus in sync with the AppKit first responder for the // active workspace. This prevents divergence between the blue focused-tab // indicator and where keyboard input/flash-focus actually lands. - guard isTabActive else { return } + guard isWorkspaceInputActive else { return } guard workspace.panels[panel.id] != nil else { return } workspace.focusPanel(panel.id) }, onRequestPanelFocus: { - guard isTabActive else { return } + guard isWorkspaceInputActive else { return } guard workspace.panels[panel.id] != nil else { return } workspace.focusPanel(panel.id) }, diff --git a/scripts/test-unit.sh b/scripts/test-unit.sh new file mode 100755 index 00000000..a35200fc --- /dev/null +++ b/scripts/test-unit.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +PROJECT="GhosttyTabs.xcodeproj" +SCHEME="cmux-unit" +CONFIGURATION="${CMUX_TEST_CONFIGURATION:-Debug}" +DESTINATION="${CMUX_TEST_DESTINATION:-platform=macOS}" + +# Default to `test` when no explicit xcodebuild action is provided. +if [ "$#" -eq 0 ]; then + set -- test +fi + +exec xcodebuild \ + -project "$PROJECT" \ + -scheme "$SCHEME" \ + -configuration "$CONFIGURATION" \ + -destination "$DESTINATION" \ + "$@"