diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 7a798706..dc05ce47 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -74,13 +74,14 @@ D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */; }; D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */; }; E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */; }; - F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */; }; - 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 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; }; - F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; }; - /* End PBXBuildFile section */ + F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */; }; + 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 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; }; + F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; }; + F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; }; + /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ A5001020 /* Embed Frameworks */ = { @@ -199,16 +200,17 @@ B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiWindowNotificationsUITests.swift; sourceTree = ""; }; B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWorkspaceConfirmDialogUITests.swift; sourceTree = ""; }; B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWorkspaceCmdDUITests.swift; sourceTree = ""; }; - 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 = ""; }; - F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = ""; }; - F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = ""; }; - /* End PBXFileReference section */ + 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 = ""; }; + F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = ""; }; + F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = ""; }; + F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = ""; }; + /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ A5001030 /* Frameworks */ = { @@ -408,19 +410,20 @@ path = cmuxUITests; sourceTree = ""; }; - F1000003A1B2C3D4E5F60718 /* cmuxTests */ = { - isa = PBXGroup; - children = ( - F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */, - F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */, - F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */, - F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */, - F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */, - F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */, - ); - path = cmuxTests; - sourceTree = ""; - }; + F1000003A1B2C3D4E5F60718 /* cmuxTests */ = { + isa = PBXGroup; + children = ( + F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */, + F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */, + F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */, + F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */, + F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */, + F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */, + F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */, + ); + path = cmuxTests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -611,20 +614,21 @@ ); 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 /* SessionPersistenceTests.swift in Sources */, - F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - B9000006A1B2C3D4E5F60719 /* Sources */ = { + 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 /* SessionPersistenceTests.swift in Sources */, + F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */, + F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B9000006A1B2C3D4E5F60719 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 68e717ed..6643f58b 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4185,9 +4185,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 @@ -4207,9 +4209,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 @@ -4231,6 +4235,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) { @@ -5082,32 +5117,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)" ) } } @@ -5195,6 +5234,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 @@ -5218,6 +5267,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 7e6658f6..d107e54e 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -3265,17 +3265,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) ) @@ -3364,14 +3366,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 12265867..11664821 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -3147,6 +3147,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 + ) + ) + } +}