From a97e0edea8eadae895029e1ca66a06cc01d10e00 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 20:05:12 -0800 Subject: [PATCH 1/3] Add drag transfer timing logs for bonsplit tabs --- Sources/AppDelegate.swift | 173 ++++++++++++++++++++++++++++++++++++-- Sources/Workspace.swift | 112 ++++++++++++++++++++++-- 2 files changed, 271 insertions(+), 14 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index e27c6195..34497d6c 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1113,19 +1113,67 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent focus: Bool = true, focusWindow: Bool = true ) -> Bool { - guard let source = locateSurface(surfaceId: panelId), - let sourceWorkspace = source.tabManager.tabs.first(where: { $0.id == source.workspaceId }), - let destinationManager = tabManagerFor(tabId: targetWorkspaceId), - let destinationWorkspace = destinationManager.tabs.first(where: { $0.id == targetWorkspaceId }) else { +#if DEBUG + let moveStart = ProcessInfo.processInfo.systemUptime + let splitLabel = splitTarget.map { split in + "\(split.orientation.rawValue):\(split.insertFirst ? 1 : 0)" + } ?? "none" + func elapsedMs(since start: TimeInterval) -> String { + let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 + return String(format: "%.2f", ms) + } + dlog( + "surface.move.begin panel=\(panelId.uuidString.prefix(5)) targetWs=\(targetWorkspaceId.uuidString.prefix(5)) " + + "targetPane=\(targetPane?.id.uuidString.prefix(5) ?? "auto") targetIndex=\(targetIndex.map(String.init) ?? "nil") " + + "split=\(splitLabel) focus=\(focus ? 1 : 0) focusWindow=\(focusWindow ? 1 : 0)" + ) +#endif + guard let source = locateSurface(surfaceId: panelId) else { +#if DEBUG + dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sourcePanelNotFound elapsedMs=\(elapsedMs(since: moveStart))") +#endif return false } + guard let sourceWorkspace = source.tabManager.tabs.first(where: { $0.id == source.workspaceId }) else { +#if DEBUG + dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sourceWorkspaceMissing elapsedMs=\(elapsedMs(since: moveStart))") +#endif + return false + } + guard let destinationManager = tabManagerFor(tabId: targetWorkspaceId) else { +#if DEBUG + dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=destinationManagerMissing elapsedMs=\(elapsedMs(since: moveStart))") +#endif + return false + } + guard let destinationWorkspace = destinationManager.tabs.first(where: { $0.id == targetWorkspaceId }) else { +#if DEBUG + dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=destinationWorkspaceMissing elapsedMs=\(elapsedMs(since: moveStart))") +#endif + return false + } +#if DEBUG + dlog( + "surface.move.route panel=\(panelId.uuidString.prefix(5)) sourceWs=\(sourceWorkspace.id.uuidString.prefix(5)) " + + "sourceWin=\(source.windowId.uuidString.prefix(5)) destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) " + + "sameWorkspace=\(destinationWorkspace.id == sourceWorkspace.id ? 1 : 0)" + ) +#endif let resolvedTargetPane = targetPane.flatMap { pane in destinationWorkspace.bonsplitController.allPaneIds.first(where: { $0 == pane }) } ?? destinationWorkspace.bonsplitController.focusedPaneId ?? destinationWorkspace.bonsplitController.allPaneIds.first - guard let resolvedTargetPane else { return false } + guard let resolvedTargetPane else { +#if DEBUG + dlog( + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=targetPaneMissing " + + "destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return false + } if destinationWorkspace.id == sourceWorkspace.id { if let splitTarget { @@ -1136,26 +1184,62 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent movingTab: sourceTabId, insertFirst: splitTarget.insertFirst ) != nil else { +#if DEBUG + dlog( + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sameWorkspaceSplitFailed " + + "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) split=\(splitLabel) " + + "elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif return false } if focus { source.tabManager.focusTab(sourceWorkspace.id, surfaceId: panelId, suppressFlash: true) } +#if DEBUG + dlog( + "surface.move.end panel=\(panelId.uuidString.prefix(5)) path=sameWorkspaceSplit moved=1 " + + "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif return true } - return sourceWorkspace.moveSurface( + let moved = sourceWorkspace.moveSurface( panelId: panelId, toPane: resolvedTargetPane, atIndex: targetIndex, focus: focus ) +#if DEBUG + dlog( + "surface.move.end panel=\(panelId.uuidString.prefix(5)) path=sameWorkspaceMove moved=\(moved ? 1 : 0) " + + "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) targetIndex=\(targetIndex.map(String.init) ?? "nil") " + + "elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return moved } let sourcePane = sourceWorkspace.paneId(forPanelId: panelId) let sourceIndex = sourceWorkspace.indexInPane(forPanelId: panelId) +#if DEBUG + let detachStart = ProcessInfo.processInfo.systemUptime +#endif - guard let detached = sourceWorkspace.detachSurface(panelId: panelId) else { return false } + guard let detached = sourceWorkspace.detachSurface(panelId: panelId) else { +#if DEBUG + dlog( + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=detachFailed " + + "elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return false + } +#if DEBUG + let detachMs = elapsedMs(since: detachStart) + let attachStart = ProcessInfo.processInfo.systemUptime +#endif guard destinationWorkspace.attachDetachedSurface( detached, inPane: resolvedTargetPane, @@ -1169,10 +1253,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent sourceIndex: sourceIndex, focus: focus ) +#if DEBUG + dlog( + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=attachFailed " + + "detachMs=\(detachMs) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif return false } +#if DEBUG + let attachMs = elapsedMs(since: attachStart) + var splitMs = "0.00" +#endif if let splitTarget { +#if DEBUG + let splitStart = ProcessInfo.processInfo.systemUptime +#endif guard let movedTabId = destinationWorkspace.surfaceIdFromPanelId(panelId), destinationWorkspace.bonsplitController.splitPane( resolvedTargetPane, @@ -1189,15 +1286,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent focus: focus ) } +#if DEBUG + dlog( + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=postAttachSplitFailed " + + "detachMs=\(detachMs) attachMs=\(attachMs) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif return false } +#if DEBUG + splitMs = elapsedMs(since: splitStart) +#endif } +#if DEBUG + let cleanupStart = ProcessInfo.processInfo.systemUptime +#endif cleanupEmptySourceWorkspaceAfterSurfaceMove( sourceWorkspace: sourceWorkspace, sourceManager: source.tabManager, sourceWindowId: source.windowId ) +#if DEBUG + let cleanupMs = elapsedMs(since: cleanupStart) + let focusStart = ProcessInfo.processInfo.systemUptime +#endif if focus { let destinationWindowId = focusWindow ? windowId(for: destinationManager) : nil @@ -1215,6 +1328,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } } +#if DEBUG + let focusMs = elapsedMs(since: focusStart) + dlog( + "surface.move.end panel=\(panelId.uuidString.prefix(5)) path=crossWorkspace moved=1 " + + "sourceWs=\(sourceWorkspace.id.uuidString.prefix(5)) destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) " + + "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) targetIndex=\(targetIndex.map(String.init) ?? "nil") " + + "split=\(splitLabel) detachMs=\(detachMs) attachMs=\(attachMs) splitMs=\(splitMs) " + + "cleanupMs=\(cleanupMs) focusMs=\(focusMs) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif return true } @@ -1229,8 +1352,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent focus: Bool = true, focusWindow: Bool = true ) -> Bool { - guard let located = locateBonsplitSurface(tabId: tabId) else { return false } - return moveSurface( +#if DEBUG + let moveStart = ProcessInfo.processInfo.systemUptime + func elapsedMs(since start: TimeInterval) -> String { + let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 + return String(format: "%.2f", ms) + } + dlog( + "surface.moveBonsplit.begin tab=\(tabId.uuidString.prefix(5)) targetWs=\(targetWorkspaceId.uuidString.prefix(5)) " + + "targetPane=\(targetPane?.id.uuidString.prefix(5) ?? "auto") targetIndex=\(targetIndex.map(String.init) ?? "nil")" + ) +#endif + guard let located = locateBonsplitSurface(tabId: tabId) else { +#if DEBUG + dlog( + "surface.moveBonsplit.fail tab=\(tabId.uuidString.prefix(5)) reason=tabNotFound " + + "targetWs=\(targetWorkspaceId.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return false + } +#if DEBUG + dlog( + "surface.moveBonsplit.located tab=\(tabId.uuidString.prefix(5)) panel=\(located.panelId.uuidString.prefix(5)) " + + "sourceWs=\(located.workspaceId.uuidString.prefix(5)) sourceWin=\(located.windowId.uuidString.prefix(5))" + ) +#endif + let moved = moveSurface( panelId: located.panelId, toWorkspace: targetWorkspaceId, targetPane: targetPane, @@ -1239,6 +1387,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent focus: focus, focusWindow: focusWindow ) +#if DEBUG + dlog( + "surface.moveBonsplit.end tab=\(tabId.uuidString.prefix(5)) panel=\(located.panelId.uuidString.prefix(5)) " + + "moved=\(moved ? 1 : 0) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return moved } func tabManagerFor(windowId: UUID) -> TabManager? { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 4aee7153..8877961d 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -568,6 +568,8 @@ final class Workspace: Identifiable, ObservableObject { private var focusReconcileScheduled = false #if DEBUG private(set) var debugFocusReconcileScheduledDuringDetachCount: Int = 0 + private var debugLastDidMoveTabTimestamp: TimeInterval = 0 + private var debugDidMoveTabEventCount: UInt64 = 0 #endif private var geometryReconcileScheduled = false private var isNormalizingPinnedTabOrder = false @@ -600,6 +602,13 @@ final class Workspace: Identifiable, ObservableObject { private var activeDetachCloseTransactions: Int = 0 private var isDetachingCloseTransaction: Bool { activeDetachCloseTransactions > 0 } +#if DEBUG + private func debugElapsedMs(since start: TimeInterval) -> String { + let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 + return String(format: "%.2f", ms) + } +#endif + func panelIdFromSurfaceId(_ surfaceId: TabID) -> UUID? { surfaceIdToPanelId[surfaceId] } @@ -1686,6 +1695,14 @@ final class Workspace: Identifiable, ObservableObject { func detachSurface(panelId: UUID) -> DetachedSurfaceTransfer? { guard let tabId = surfaceIdFromPanelId(panelId) else { return nil } guard panels[panelId] != nil else { return nil } +#if DEBUG + let detachStart = ProcessInfo.processInfo.systemUptime + dlog( + "split.detach.begin ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " + + "tab=\(tabId.uuid.uuidString.prefix(5)) activeDetachTxn=\(activeDetachCloseTransactions) " + + "pendingDetached=\(pendingDetachedSurfaces.count)" + ) +#endif detachingTabIds.insert(tabId) forceCloseTabIds.insert(tabId) @@ -1695,10 +1712,24 @@ final class Workspace: Identifiable, ObservableObject { detachingTabIds.remove(tabId) pendingDetachedSurfaces.removeValue(forKey: tabId) forceCloseTabIds.remove(tabId) +#if DEBUG + dlog( + "split.detach.fail ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " + + "tab=\(tabId.uuid.uuidString.prefix(5)) reason=closeTabRejected elapsedMs=\(debugElapsedMs(since: detachStart))" + ) +#endif return nil } - return pendingDetachedSurfaces.removeValue(forKey: tabId) + let detached = pendingDetachedSurfaces.removeValue(forKey: tabId) +#if DEBUG + dlog( + "split.detach.end ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " + + "tab=\(tabId.uuid.uuidString.prefix(5)) transfer=\(detached != nil ? 1 : 0) " + + "elapsedMs=\(debugElapsedMs(since: detachStart))" + ) +#endif + return detached } @discardableResult @@ -1708,8 +1739,31 @@ final class Workspace: Identifiable, ObservableObject { atIndex index: Int? = nil, focus: Bool = true ) -> UUID? { - guard bonsplitController.allPaneIds.contains(paneId) else { return nil } - guard panels[detached.panelId] == nil else { return nil } +#if DEBUG + let attachStart = ProcessInfo.processInfo.systemUptime + dlog( + "split.attach.begin ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + + "pane=\(paneId.id.uuidString.prefix(5)) index=\(index.map(String.init) ?? "nil") focus=\(focus ? 1 : 0)" + ) +#endif + guard bonsplitController.allPaneIds.contains(paneId) else { +#if DEBUG + dlog( + "split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + + "reason=invalidPane elapsedMs=\(debugElapsedMs(since: attachStart))" + ) +#endif + return nil + } + guard panels[detached.panelId] == nil else { +#if DEBUG + dlog( + "split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + + "reason=panelExists elapsedMs=\(debugElapsedMs(since: attachStart))" + ) +#endif + return nil + } panels[detached.panelId] = detached.panel if let terminalPanel = detached.panel as? TerminalPanel { @@ -1760,6 +1814,12 @@ final class Workspace: Identifiable, ObservableObject { manualUnreadPanelIds.remove(detached.panelId) manualUnreadMarkedAt.removeValue(forKey: detached.panelId) panelSubscriptions.removeValue(forKey: detached.panelId) +#if DEBUG + dlog( + "split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + + "reason=createTabFailed elapsedMs=\(debugElapsedMs(since: attachStart))" + ) +#endif return nil } @@ -1781,6 +1841,14 @@ final class Workspace: Identifiable, ObservableObject { } scheduleTerminalGeometryReconcile() +#if DEBUG + dlog( + "split.attach.end ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + + "tab=\(newTabId.uuid.uuidString.prefix(5)) pane=\(paneId.id.uuidString.prefix(5)) " + + "index=\(index.map(String.init) ?? "nil") focus=\(focus ? 1 : 0) " + + "elapsedMs=\(debugElapsedMs(since: attachStart))" + ) +#endif return detached.panelId } // MARK: - Focus Management @@ -2329,23 +2397,41 @@ final class Workspace: Identifiable, ObservableObject { private func handleExternalTabDrop(_ request: BonsplitController.ExternalTabDropRequest) -> Bool { guard let app = AppDelegate.shared else { return false } +#if DEBUG + let dropStart = ProcessInfo.processInfo.systemUptime +#endif let targetPane: PaneID let targetIndex: Int? let splitTarget: (orientation: SplitOrientation, insertFirst: Bool)? +#if DEBUG + let destinationLabel: String +#endif switch request.destination { case .insert(let paneId, let index): targetPane = paneId targetIndex = index splitTarget = nil +#if DEBUG + destinationLabel = "insert pane=\(paneId.id.uuidString.prefix(5)) index=\(index.map(String.init) ?? "nil")" +#endif case .split(let paneId, let orientation, let insertFirst): targetPane = paneId targetIndex = nil splitTarget = (orientation, insertFirst) +#if DEBUG + destinationLabel = "split pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation.rawValue) insertFirst=\(insertFirst ? 1 : 0)" +#endif } - return app.moveBonsplitTab( + #if DEBUG + dlog( + "split.externalDrop.begin ws=\(id.uuidString.prefix(5)) tab=\(request.tabId.uuid.uuidString.prefix(5)) " + + "sourcePane=\(request.sourcePaneId.id.uuidString.prefix(5)) destination=\(destinationLabel)" + ) + #endif + let moved = app.moveBonsplitTab( tabId: request.tabId.uuid, toWorkspace: id, targetPane: targetPane, @@ -2354,6 +2440,13 @@ final class Workspace: Identifiable, ObservableObject { focus: true, focusWindow: true ) +#if DEBUG + dlog( + "split.externalDrop.end ws=\(id.uuidString.prefix(5)) tab=\(request.tabId.uuid.uuidString.prefix(5)) " + + "moved=\(moved ? 1 : 0) elapsedMs=\(debugElapsedMs(since: dropStart))" + ) +#endif + return moved } } @@ -2736,9 +2829,18 @@ extension Workspace: BonsplitDelegate { func splitTabBar(_ controller: BonsplitController, didMoveTab tab: Bonsplit.Tab, fromPane source: PaneID, toPane destination: PaneID) { #if DEBUG + let now = ProcessInfo.processInfo.systemUptime + let sincePrev: String + if debugLastDidMoveTabTimestamp > 0 { + sincePrev = String(format: "%.2f", (now - debugLastDidMoveTabTimestamp) * 1000) + } else { + sincePrev = "first" + } + debugLastDidMoveTabTimestamp = now + debugDidMoveTabEventCount += 1 let movedPanel = panelIdFromSurfaceId(tab.id)?.uuidString.prefix(5) ?? "unknown" dlog( - "split.moveTab panel=\(movedPanel) " + + "split.moveTab idx=\(debugDidMoveTabEventCount) dtSincePrevMs=\(sincePrev) panel=\(movedPanel) " + "from=\(source.id.uuidString.prefix(5)) to=\(destination.id.uuidString.prefix(5)) " + "sourceTabs=\(controller.tabs(inPane: source).count) destTabs=\(controller.tabs(inPane: destination).count)" ) From fb1802a54d64c0a228c5ea58398f845b62a51940 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 20:20:55 -0800 Subject: [PATCH 2/3] Guard terminal onFocus from re-entrant focus loops --- Sources/GhosttyTerminalView.swift | 4 +- Sources/Workspace.swift | 33 ++++++++-- Sources/WorkspaceContentView.swift | 2 +- ..._focus_panel_reentrant_guard_regression.py | 64 +++++++++++++++++++ 4 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 tests/test_focus_panel_reentrant_guard_regression.py diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index d5d0d719..15068928 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2517,6 +2517,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { override func becomeFirstResponder() -> Bool { let result = super.becomeFirstResponder() + var shouldApplySurfaceFocus = false if result { // If we become first responder before the ghostty surface exists (e.g. during // split/tab creation while the surface is still being created), record the desired focus. @@ -2538,6 +2539,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // stayed behind. let hiddenInHierarchy = isHiddenOrHasHiddenAncestor if isVisibleInUI && hasUsableFocusGeometry && !hiddenInHierarchy { + shouldApplySurfaceFocus = true onFocus?() } else if isVisibleInUI && (!hasUsableFocusGeometry || hiddenInHierarchy) { #if DEBUG @@ -2548,7 +2550,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { #endif } } - if result, let surface = ensureSurfaceReadyForInput() { + if result, shouldApplySurfaceFocus, let surface = ensureSurfaceReadyForInput() { let now = CACurrentMediaTime() let deltaMs = (now - lastScrollEventTime) * 1000 Self.focusLog("becomeFirstResponder: surface=\(terminalSurface?.id.uuidString ?? "nil") deltaSinceScrollMs=\(String(format: "%.2f", deltaMs))") diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 8877961d..9ea80bd3 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -347,6 +347,11 @@ final class Workspace: Identifiable, ObservableObject { return panel } + enum FocusPanelTrigger { + case standard + case terminalFirstResponder + } + /// Published directory for each panel @Published var panelDirectories: [UUID: String] = [:] @Published var panelTitles: [UUID: String] = [:] @@ -1940,12 +1945,19 @@ final class Workspace: Identifiable, ObservableObject { terminalPanel.hostedView.ensureFocus(for: id, surfaceId: preferredPanelId) } - func focusPanel(_ panelId: UUID, previousHostedView: GhosttySurfaceScrollView? = nil) { + func focusPanel( + _ panelId: UUID, + previousHostedView: GhosttySurfaceScrollView? = nil, + trigger: FocusPanelTrigger = .standard + ) { markExplicitFocusIntent(on: panelId) #if DEBUG let pane = bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" - dlog("focus.panel panel=\(panelId.uuidString.prefix(5)) pane=\(pane)") - FocusLogStore.shared.append("Workspace.focusPanel panelId=\(panelId.uuidString) focusedPane=\(pane)") + let triggerLabel = trigger == .terminalFirstResponder ? "firstResponder" : "standard" + dlog("focus.panel panel=\(panelId.uuidString.prefix(5)) pane=\(pane) trigger=\(triggerLabel)") + FocusLogStore.shared.append( + "Workspace.focusPanel panelId=\(panelId.uuidString) focusedPane=\(pane) trigger=\(triggerLabel)" + ) #endif guard let tabId = surfaceIdFromPanelId(panelId) else { return } let currentlyFocusedPanelId = focusedPanelId @@ -1968,6 +1980,15 @@ final class Workspace: Identifiable, ObservableObject { return bonsplitController.focusedPaneId == targetPaneId && bonsplitController.selectedTab(inPane: targetPaneId)?.id == tabId }() + let shouldSuppressReentrantRefocus = trigger == .terminalFirstResponder && selectionAlreadyConverged +#if DEBUG + if shouldSuppressReentrantRefocus { + dlog( + "focus.panel.skipReentrant panel=\(panelId.uuidString.prefix(5)) " + + "reason=firstResponderAlreadyConverged" + ) + } +#endif if let targetPaneId, !selectionAlreadyConverged { bonsplitController.focusPane(targetPaneId) @@ -1979,11 +2000,11 @@ final class Workspace: Identifiable, ObservableObject { // Also focus the underlying panel if let panel = panels[panelId] { - if currentlyFocusedPanelId != panelId || !selectionAlreadyConverged { + if (currentlyFocusedPanelId != panelId || !selectionAlreadyConverged) && !shouldSuppressReentrantRefocus { panel.focus() } - if let terminalPanel = panel as? TerminalPanel { + if !shouldSuppressReentrantRefocus, let terminalPanel = panel as? TerminalPanel { // Avoid re-entrant focus loops when focus was initiated by AppKit first-responder // (becomeFirstResponder -> onFocus -> focusPanel). if !terminalPanel.hostedView.isSurfaceViewFirstResponder() { @@ -1991,7 +2012,7 @@ final class Workspace: Identifiable, ObservableObject { } } } - if let targetPaneId { + if let targetPaneId, !shouldSuppressReentrantRefocus { applyTabSelection(tabId: tabId, inPane: targetPaneId) } } diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index d209b4d2..392f9986 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -67,7 +67,7 @@ struct WorkspaceContentView: View { // indicator and where keyboard input/flash-focus actually lands. guard isWorkspaceInputActive else { return } guard workspace.panels[panel.id] != nil else { return } - workspace.focusPanel(panel.id) + workspace.focusPanel(panel.id, trigger: .terminalFirstResponder) }, onRequestPanelFocus: { guard isWorkspaceInputActive else { return } diff --git a/tests/test_focus_panel_reentrant_guard_regression.py b/tests/test_focus_panel_reentrant_guard_regression.py new file mode 100644 index 00000000..fbe2a5c3 --- /dev/null +++ b/tests/test_focus_panel_reentrant_guard_regression.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Static regression checks for re-entrant terminal focus guard. + +Guards the fix for split-drag focus churn where: +becomeFirstResponder -> onFocus -> Workspace.focusPanel -> refocus side-effects +could repeatedly re-enter and spike CPU. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def repo_root() -> Path: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path(__file__).resolve().parents[1] + + +def main() -> int: + root = repo_root() + failures: list[str] = [] + + workspace_path = root / "Sources" / "Workspace.swift" + workspace_source = workspace_path.read_text(encoding="utf-8") + + required_workspace_snippets = [ + "enum FocusPanelTrigger {", + "case terminalFirstResponder", + "trigger: FocusPanelTrigger = .standard", + "let shouldSuppressReentrantRefocus = trigger == .terminalFirstResponder && selectionAlreadyConverged", + "if let targetPaneId, !shouldSuppressReentrantRefocus {", + "reason=firstResponderAlreadyConverged", + ] + for snippet in required_workspace_snippets: + if snippet not in workspace_source: + failures.append(f"Workspace focus guard missing snippet: {snippet}") + + workspace_content_view_path = root / "Sources" / "WorkspaceContentView.swift" + workspace_content_view_source = workspace_content_view_path.read_text(encoding="utf-8") + focus_callback_snippet = "workspace.focusPanel(panel.id, trigger: .terminalFirstResponder)" + if focus_callback_snippet not in workspace_content_view_source: + failures.append( + "WorkspaceContentView terminal onFocus callback no longer passes .terminalFirstResponder trigger" + ) + + if failures: + print("FAIL: focus-panel re-entrant guard regression checks failed") + for item in failures: + print(f" - {item}") + return 1 + + print("PASS: focus-panel re-entrant guard is in place") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From cd030732406d84aac71e75ec273d5be7e3cebc29 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 23:57:09 -0800 Subject: [PATCH 3/3] Fix tab drag blank state and preserve non-custom titles across window drops --- GhosttyTabs.xcodeproj/project.pbxproj | 56 +++++++------- Sources/GhosttyTerminalView.swift | 77 ++++++++++++++++--- Sources/Workspace.swift | 29 ++++++- Sources/WorkspaceContentView.swift | 17 +++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 45 +++++++++++ .../WorkspaceContentViewVisibilityTests.swift | 49 ++++++++++++ 6 files changed, 234 insertions(+), 39 deletions(-) create mode 100644 cmuxTests/WorkspaceContentViewVisibilityTests.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 3448b298..82c0d154 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -77,6 +77,7 @@ F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; }; F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; }; F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; }; + F5000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -198,10 +199,11 @@ D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = ""; }; D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarSuggestionsUITests.swift; sourceTree = ""; }; E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuKeyEquivalentRoutingUITests.swift; sourceTree = ""; }; - F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewKeyEquivalentTests.swift; sourceTree = ""; }; - F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = ""; }; - F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = ""; }; - F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = ""; }; + F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewKeyEquivalentTests.swift; sourceTree = ""; }; + F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = ""; }; + F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = ""; }; + F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = ""; }; + F5000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -401,17 +403,18 @@ path = cmuxUITests; sourceTree = ""; }; - F1000003A1B2C3D4E5F60718 /* cmuxTests */ = { - isa = PBXGroup; - children = ( - F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */, - F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */, - F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */, - F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */, - ); - path = cmuxTests; - sourceTree = ""; - }; + F1000003A1B2C3D4E5F60718 /* cmuxTests */ = { + isa = PBXGroup; + children = ( + F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */, + F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */, + F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */, + F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */, + F5000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */, + ); + path = cmuxTests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -601,17 +604,18 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - F1000005A1B2C3D4E5F60718 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */, - F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */, - F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */, - F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; + F1000005A1B2C3D4E5F60718 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */, + F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */, + F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */, + F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */, + F5000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; B9000006A1B2C3D4E5F60719 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 15068928..ecb5b7dc 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4104,9 +4104,11 @@ final class GhosttySurfaceScrollView: NSView { isHidden = !visible #if DEBUG if wasVisible != visible { + let transition = "\(wasVisible ? 1 : 0)->\(visible ? 1 : 0)" + let suffix = debugVisibilityStateSuffix(transition: transition) debugLogWorkspaceSwitchTiming( event: "ws.term.visible", - suffix: "surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") value=\(visible ? 1 : 0)" + suffix: suffix ) } #endif @@ -4126,9 +4128,11 @@ final class GhosttySurfaceScrollView: NSView { isActive = active #if DEBUG if wasActive != active { + let transition = "\(wasActive ? 1 : 0)->\(active ? 1 : 0)" + let suffix = debugVisibilityStateSuffix(transition: transition) debugLogWorkspaceSwitchTiming( event: "ws.term.active", - suffix: "surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") value=\(active ? 1 : 0)" + suffix: suffix ) } #endif @@ -4150,6 +4154,37 @@ final class GhosttySurfaceScrollView: NSView { let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 dlog("\(event) id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) \(suffix)") } + + private func debugFirstResponderLabel() -> String { + guard let window, let firstResponder = window.firstResponder else { return "nil" } + if let view = firstResponder as? NSView { + if view === surfaceView { + return "surfaceView" + } + if view.isDescendant(of: surfaceView) { + return "surfaceDescendant" + } + return String(describing: type(of: view)) + } + return String(describing: type(of: firstResponder)) + } + + private func debugVisibilityStateSuffix(transition: String) -> String { + let surface = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" + let hiddenInHierarchy = (isHiddenOrHasHiddenAncestor || surfaceView.isHiddenOrHasHiddenAncestor) ? 1 : 0 + let inWindow = window != nil ? 1 : 0 + let hasSuperview = superview != nil ? 1 : 0 + let hostHidden = isHidden ? 1 : 0 + let surfaceHidden = surfaceView.isHidden ? 1 : 0 + let boundsText = String(format: "%.1fx%.1f", bounds.width, bounds.height) + let frameText = String(format: "%.1fx%.1f", frame.width, frame.height) + let responder = debugFirstResponderLabel() + return + "surface=\(surface) transition=\(transition) active=\(isActive ? 1 : 0) " + + "visibleFlag=\(surfaceView.isVisibleInUI ? 1 : 0) hostHidden=\(hostHidden) surfaceHidden=\(surfaceHidden) " + + "hiddenHierarchy=\(hiddenInHierarchy) inWindow=\(inWindow) hasSuperview=\(hasSuperview) " + + "bounds=\(boundsText) frame=\(frameText) firstResponder=\(responder)" + } #endif func moveFocus(from previous: GhosttySurfaceScrollView? = nil, delay: TimeInterval? = nil) { @@ -5001,32 +5036,36 @@ struct GhosttyTerminalView: NSViewRepresentable { func updateNSView(_ nsView: NSView, context: Context) { let hostedView = terminalSurface.hostedView let coordinator = context.coordinator -#if DEBUG let previousDesiredIsActive = coordinator.desiredIsActive -#endif let previousDesiredIsVisibleInUI = coordinator.desiredIsVisibleInUI let previousDesiredShowsUnreadNotificationRing = coordinator.desiredShowsUnreadNotificationRing let previousDesiredPortalZPriority = coordinator.desiredPortalZPriority + let desiredStateChanged = + previousDesiredIsActive != isActive || + previousDesiredIsVisibleInUI != isVisibleInUI || + previousDesiredPortalZPriority != portalZPriority coordinator.desiredIsActive = isActive coordinator.desiredIsVisibleInUI = isVisibleInUI coordinator.desiredShowsUnreadNotificationRing = showsUnreadNotificationRing coordinator.desiredPortalZPriority = portalZPriority coordinator.hostedView = hostedView #if DEBUG - if previousDesiredIsActive != isActive || - previousDesiredIsVisibleInUI != isVisibleInUI || - previousDesiredPortalZPriority != portalZPriority { + if desiredStateChanged { 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)" + "active=\(isActive ? 1 : 0) z=\(portalZPriority) " + + "hostWindow=\(nsView.window != nil ? 1 : 0) hostedWindow=\(hostedView.window != nil ? 1 : 0) " + + "hostedSuperview=\(hostedView.superview != nil ? 1 : 0)" ) } else { dlog( "ws.swiftui.update id=none surface=\(terminalSurface.id.uuidString.prefix(5)) " + - "visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0) z=\(portalZPriority)" + "visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0) z=\(portalZPriority) " + + "hostWindow=\(nsView.window != nil ? 1 : 0) hostedWindow=\(hostedView.window != nil ? 1 : 0) " + + "hostedSuperview=\(hostedView.superview != nil ? 1 : 0)" ) } } @@ -5114,6 +5153,16 @@ struct GhosttyTerminalView: NSViewRepresentable { // Bind is deferred until host moves into a window. Update the // existing portal entry's visibleInUI now so that any portal sync // that runs before the deferred bind completes won't hide the view. +#if DEBUG + if desiredStateChanged { + dlog( + "ws.hostState.deferBind surface=\(terminalSurface.id.uuidString.prefix(5)) " + + "reason=hostNoWindow visible=\(coordinator.desiredIsVisibleInUI ? 1 : 0) " + + "active=\(coordinator.desiredIsActive ? 1 : 0) z=\(coordinator.desiredPortalZPriority) " + + "hostedWindow=\(hostedView.window != nil ? 1 : 0) hostedSuperview=\(hostedView.superview != nil ? 1 : 0)" + ) + } +#endif TerminalWindowPortalRegistry.updateEntryVisibility( for: hostedView, visibleInUI: coordinator.desiredIsVisibleInUI @@ -5137,6 +5186,16 @@ struct GhosttyTerminalView: NSViewRepresentable { } else { // Preserve portal entry visibility while a stale host is still receiving SwiftUI updates. // The currently bound host remains authoritative for immediate visible/active state. +#if DEBUG + if desiredStateChanged { + dlog( + "ws.hostState.deferApply surface=\(terminalSurface.id.uuidString.prefix(5)) " + + "reason=staleHostBinding hostWindow=\(hostWindowAttached ? 1 : 0) " + + "boundToCurrent=\(isBoundToCurrentHost ? 1 : 0) hostedSuperview=\(hostedView.superview != nil ? 1 : 0) " + + "visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0)" + ) + } +#endif TerminalWindowPortalRegistry.updateEntryVisibility( for: hostedView, visibleInUI: isVisibleInUI diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 9ea80bd3..582f1d33 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -2761,17 +2761,19 @@ extension Workspace: BonsplitDelegate { if isDetaching, let panel { let browserPanel = panel as? BrowserPanel + let cachedTitle = panelTitles[panelId] + let transferFallbackTitle = cachedTitle ?? panel.displayTitle pendingDetachedSurfaces[tabId] = DetachedSurfaceTransfer( panelId: panelId, panel: panel, - title: resolvedPanelTitle(panelId: panelId, fallback: panel.displayTitle), + title: resolvedPanelTitle(panelId: panelId, fallback: transferFallbackTitle), icon: panel.displayIcon, iconImageData: browserPanel?.faviconPNGData, kind: surfaceKind(for: panel), isLoading: browserPanel?.isLoading ?? false, isPinned: pinnedPanelIds.contains(panelId), directory: panelDirectories[panelId], - cachedTitle: panelTitles[panelId], + cachedTitle: cachedTitle, customTitle: panelCustomTitles[panelId], manuallyUnread: manualUnreadPanelIds.contains(panelId) ) @@ -2859,14 +2861,35 @@ extension Workspace: BonsplitDelegate { } debugLastDidMoveTabTimestamp = now debugDidMoveTabEventCount += 1 - let movedPanel = panelIdFromSurfaceId(tab.id)?.uuidString.prefix(5) ?? "unknown" + let movedPanelId = panelIdFromSurfaceId(tab.id) + let movedPanel = movedPanelId?.uuidString.prefix(5) ?? "unknown" + let selectedBefore = controller.selectedTab(inPane: destination) + .map { String(String(describing: $0.id).prefix(5)) } ?? "nil" + let focusedPaneBefore = controller.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" + let focusedPanelBefore = focusedPanelId?.uuidString.prefix(5) ?? "nil" dlog( "split.moveTab idx=\(debugDidMoveTabEventCount) dtSincePrevMs=\(sincePrev) panel=\(movedPanel) " + "from=\(source.id.uuidString.prefix(5)) to=\(destination.id.uuidString.prefix(5)) " + "sourceTabs=\(controller.tabs(inPane: source).count) destTabs=\(controller.tabs(inPane: destination).count)" ) + dlog( + "split.moveTab.state.before idx=\(debugDidMoveTabEventCount) panel=\(movedPanel) " + + "destSelected=\(selectedBefore) focusedPane=\(focusedPaneBefore) focusedPanel=\(focusedPanelBefore)" + ) #endif applyTabSelection(tabId: tab.id, inPane: destination) +#if DEBUG + let selectedAfter = controller.selectedTab(inPane: destination) + .map { String(String(describing: $0.id).prefix(5)) } ?? "nil" + let focusedPaneAfter = controller.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" + let focusedPanelAfter = focusedPanelId?.uuidString.prefix(5) ?? "nil" + let movedPanelFocused = (movedPanelId != nil && movedPanelId == focusedPanelId) ? 1 : 0 + dlog( + "split.moveTab.state.after idx=\(debugDidMoveTabEventCount) panel=\(movedPanel) " + + "destSelected=\(selectedAfter) focusedPane=\(focusedPaneAfter) focusedPanel=\(focusedPanelAfter) " + + "movedFocused=\(movedPanelFocused)" + ) +#endif normalizePinnedTabs(in: source) normalizePinnedTabs(in: destination) scheduleTerminalGeometryReconcile() diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 392f9986..3e058a47 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -19,6 +19,17 @@ struct WorkspaceContentView: View { @Environment(\.colorScheme) private var colorScheme @EnvironmentObject var notificationStore: TerminalNotificationStore + static func panelVisibleInUI( + isWorkspaceVisible: Bool, + isSelectedInPane: Bool, + isFocused: Bool + ) -> Bool { + guard isWorkspaceVisible else { return false } + // During pane/tab reparenting, Bonsplit can transiently report selected=false + // for the currently focused panel. Keep focused content visible to avoid blank frames. + return isSelectedInPane || isFocused + } + var body: some View { let appearance = PanelAppearance.fromConfig(config) let isSplit = workspace.bonsplitController.allPaneIds.count > 1 || @@ -47,7 +58,11 @@ struct WorkspaceContentView: View { if let panel = workspace.panel(for: tab.id) { let isFocused = isWorkspaceInputActive && workspace.focusedPanelId == panel.id let isSelectedInPane = workspace.bonsplitController.selectedTab(inPane: paneId)?.id == tab.id - let isVisibleInUI = isWorkspaceVisible && isSelectedInPane + let isVisibleInUI = Self.panelVisibleInUI( + isWorkspaceVisible: isWorkspaceVisible, + isSelectedInPane: isSelectedInPane, + isFocused: isFocused + ) let hasUnreadNotification = Workspace.shouldShowUnreadIndicator( hasUnreadNotification: notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panel.id), isManuallyUnread: workspace.manualUnreadPanelIds.contains(panel.id) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 7f5dcb51..eb305a1d 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -2973,6 +2973,51 @@ final class WorkspacePanelGitBranchTests: XCTestCase { #endif } + func testDetachAttachAcrossWorkspacesPreservesNonCustomPanelTitle() { + let source = Workspace() + guard let panelId = source.focusedPanelId else { + XCTFail("Expected source focused panel") + return + } + + XCTAssertTrue(source.updatePanelTitle(panelId: panelId, title: "detached-runtime-title")) + + guard let detached = source.detachSurface(panelId: panelId) else { + XCTFail("Expected detach to succeed") + return + } + + XCTAssertEqual(detached.cachedTitle, "detached-runtime-title") + XCTAssertNil(detached.customTitle) + XCTAssertEqual( + detached.title, + "detached-runtime-title", + "Detached transfer should carry the cached non-custom title" + ) + + let destination = Workspace() + guard let destinationPane = destination.bonsplitController.allPaneIds.first else { + XCTFail("Expected destination pane") + return + } + + let attachedPanelId = destination.attachDetachedSurface( + detached, + inPane: destinationPane, + focus: false + ) + XCTAssertEqual(attachedPanelId, panelId) + XCTAssertEqual(destination.panelTitle(panelId: panelId), "detached-runtime-title") + + guard let attachedTabId = destination.surfaceIdFromPanelId(panelId), + let attachedTab = destination.bonsplitController.tab(attachedTabId) else { + XCTFail("Expected attached tab mapping") + return + } + XCTAssertEqual(attachedTab.title, "detached-runtime-title") + XCTAssertFalse(attachedTab.hasCustomTitle) + } + func testBrowserSplitWithFocusFalseRecoversFromDelayedStaleSelection() { let workspace = Workspace() guard let originalFocusedPanelId = workspace.focusedPanelId else { diff --git a/cmuxTests/WorkspaceContentViewVisibilityTests.swift b/cmuxTests/WorkspaceContentViewVisibilityTests.swift new file mode 100644 index 00000000..6e8d62e3 --- /dev/null +++ b/cmuxTests/WorkspaceContentViewVisibilityTests.swift @@ -0,0 +1,49 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class WorkspaceContentViewVisibilityTests: XCTestCase { + func testPanelVisibleInUIReturnsFalseWhenWorkspaceHidden() { + XCTAssertFalse( + WorkspaceContentView.panelVisibleInUI( + isWorkspaceVisible: false, + isSelectedInPane: true, + isFocused: true + ) + ) + } + + func testPanelVisibleInUIReturnsTrueForSelectedPanel() { + XCTAssertTrue( + WorkspaceContentView.panelVisibleInUI( + isWorkspaceVisible: true, + isSelectedInPane: true, + isFocused: false + ) + ) + } + + func testPanelVisibleInUIReturnsTrueForFocusedPanelDuringTransientSelectionGap() { + XCTAssertTrue( + WorkspaceContentView.panelVisibleInUI( + isWorkspaceVisible: true, + isSelectedInPane: false, + isFocused: true + ) + ) + } + + func testPanelVisibleInUIReturnsFalseWhenNeitherSelectedNorFocused() { + XCTAssertFalse( + WorkspaceContentView.panelVisibleInUI( + isWorkspaceVisible: true, + isSelectedInPane: false, + isFocused: false + ) + ) + } +}