diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index a0a4d27f..f43fb59c 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -9980,7 +9980,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent context.sidebarSelectionState.selection = .tabs bringToFront(window) - context.tabManager.focusTabFromNotification(tabId, surfaceId: surfaceId) + guard context.tabManager.focusTabFromNotification(tabId, surfaceId: surfaceId) else { +#if DEBUG + recordMultiWindowNotificationOpenFailureIfNeeded( + tabId: tabId, + surfaceId: surfaceId, + notificationId: notificationId, + reason: "focus_failed" + ) + if ProcessInfo.processInfo.environment["CMUX_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { + writeJumpUnreadTestData(["jumpUnreadOpenResult": "0"]) + } +#endif + return false + } #if DEBUG // UI test support: Jump-to-unread asserts that the correct workspace/panel is focused. @@ -10045,7 +10058,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent sidebarSelectionState?.selection = .tabs bringToFront(window) - tabManager.focusTabFromNotification(tabId, surfaceId: surfaceId) + guard tabManager.focusTabFromNotification(tabId, surfaceId: surfaceId) else { +#if DEBUG + if ProcessInfo.processInfo.environment["CMUX_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { + writeJumpUnreadTestData([ + "jumpUnreadFallbackFail": "focus_failed", + "jumpUnreadOpenResult": "0", + ]) + } +#endif + return false + } #if DEBUG recordJumpUnreadFocusFromModelIfNeeded( diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 311166e9..fb666864 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -2214,14 +2214,33 @@ class TabManager: ObservableObject { } } - func focusTabFromNotification(_ tabId: UUID, surfaceId: UUID? = nil) { + @discardableResult + func focusTabFromNotification(_ tabId: UUID, surfaceId: UUID? = nil) -> Bool { let wasSelected = selectedTabId == tabId - let desiredPanelId = surfaceId ?? tabs.first(where: { $0.id == tabId })?.focusedPanelId + guard let tab = tabs.first(where: { $0.id == tabId }) else { +#if DEBUG + dlog("notification.focus.fail tab=\(tabId.uuidString.prefix(5)) reason=missingTab") +#endif + return false + } + if let surfaceId, tab.panels[surfaceId] == nil { +#if DEBUG + dlog( + "notification.focus.fail tab=\(tabId.uuidString.prefix(5)) " + + "panel=\(surfaceId.uuidString.prefix(5)) reason=missingPanel" + ) +#endif + return false + } + let desiredPanelId = surfaceId ?? tab.focusedPanelId #if DEBUG if let desiredPanelId { AppDelegate.shared?.armJumpUnreadFocusRecord(tabId: tabId, surfaceId: desiredPanelId) } #endif + // Jump-to-unread should reveal the destination pane instead of keeping an old split-zoom + // state active around it. + tab.clearSplitZoom() suppressFocusFlash = true focusTab(tabId, surfaceId: desiredPanelId, suppressFlash: true) if wasSelected { @@ -2239,6 +2258,7 @@ class TabManager: ObservableObject { tab.triggerNotificationFocusFlash(panelId: targetPanelId, requiresSplit: false, shouldFocus: true) notificationStore.markRead(forTabId: tabId, surfaceId: targetPanelId) } + return true } func focusSurface(tabId: UUID, surfaceId: UUID) { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 2ac932ce..28229531 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -11142,7 +11142,9 @@ class TerminalController { result = "ERROR: Surface not found" return } - tabManager.focusTabFromNotification(tab.id, surfaceId: surfaceId) + if !tabManager.focusTabFromNotification(tab.id, surfaceId: surfaceId) { + result = "ERROR: Focus failed" + } } return result } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 21f7a4db..daad64ec 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -5589,6 +5589,43 @@ final class TabManagerCloseCurrentPanelTests: XCTestCase { } } +@MainActor +final class TabManagerNotificationFocusTests: XCTestCase { + func testFocusTabFromNotificationClearsSplitZoomBeforeFocusingTargetPanel() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + XCTFail("Expected split setup to succeed") + return + } + + workspace.focusPanel(leftPanelId) + XCTAssertTrue(workspace.toggleSplitZoom(panelId: leftPanelId), "Expected split zoom to enable") + XCTAssertTrue(workspace.bonsplitController.isSplitZoomed, "Expected workspace to start zoomed") + + XCTAssertTrue(manager.focusTabFromNotification(workspace.id, surfaceId: rightPanel.id)) + drainMainQueue() + drainMainQueue() + + XCTAssertFalse( + workspace.bonsplitController.isSplitZoomed, + "Expected notification focus to exit split zoom so the target pane becomes visible" + ) + XCTAssertEqual(workspace.focusedPanelId, rightPanel.id, "Expected notification target panel to be focused") + } + + func testFocusTabFromNotificationReturnsFalseForMissingPanel() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace else { + XCTFail("Expected selected workspace") + return + } + + XCTAssertFalse(manager.focusTabFromNotification(workspace.id, surfaceId: UUID())) + } +} + @MainActor final class TabManagerPendingUnfocusPolicyTests: XCTestCase { func testDoesNotUnfocusWhenPendingTabIsCurrentlySelected() {