diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 41a57562..ee98942f 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -37,6 +37,38 @@ private enum CmuxThemeNotifications { static let reloadConfig = Notification.Name("com.cmuxterm.themes.reload-config") } +func isCommandPaletteFocusStealingTerminalOrBrowserResponder(_ responder: NSResponder) -> Bool { + if responder is GhosttyNSView || responder is WKWebView { + return true + } + + if let textView = responder as? NSTextView, + !textView.isFieldEditor, + let delegateView = textView.delegate as? NSView { + return isCommandPaletteFocusStealingTerminalOrBrowserView(delegateView) + } + + if let view = responder as? NSView { + return isCommandPaletteFocusStealingTerminalOrBrowserView(view) + } + + return false +} + +func isCommandPaletteFocusStealingTerminalOrBrowserView(_ view: NSView) -> Bool { + if view is GhosttyNSView || view is GhosttySurfaceScrollView || view is WKWebView { + return true + } + var current: NSView? = view.superview + while let candidate = current { + if candidate is GhosttyNSView || candidate is GhosttySurfaceScrollView || candidate is WKWebView { + return true + } + current = candidate.superview + } + return false +} + #if DEBUG enum CmuxTypingTiming { static let isEnabled: Bool = { @@ -4656,35 +4688,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func isFocusStealingResponderWhileCommandPaletteVisible(_ responder: NSResponder) -> Bool { - if responder is GhosttyNSView || responder is WKWebView { - return true - } - - if let textView = responder as? NSTextView, - !textView.isFieldEditor, - let delegateView = textView.delegate as? NSView { - return isTerminalOrBrowserView(delegateView) - } - - if let view = responder as? NSView { - return isTerminalOrBrowserView(view) - } - - return false - } - - private func isTerminalOrBrowserView(_ view: NSView) -> Bool { - if view is GhosttyNSView || view is WKWebView { - return true - } - var current: NSView? = view.superview - while let candidate = current { - if candidate is GhosttyNSView || candidate is WKWebView { - return true - } - current = candidate.superview - } - return false + isCommandPaletteFocusStealingTerminalOrBrowserResponder(responder) } private func isInsideCommandPaletteOverlay(_ view: NSView) -> Bool { diff --git a/Sources/Find/BrowserSearchOverlay.swift b/Sources/Find/BrowserSearchOverlay.swift index 66f66581..940a662d 100644 --- a/Sources/Find/BrowserSearchOverlay.swift +++ b/Sources/Find/BrowserSearchOverlay.swift @@ -222,6 +222,16 @@ private struct BrowserSearchTextFieldRepresentable: NSViewRepresentable { } } + func focusField(_ field: BrowserSearchNativeTextField, in window: NSWindow) { + guard window.makeFirstResponder(field) else { return } + DispatchQueue.main.async { [weak field] in + guard let field, + let editor = field.currentEditor() as? NSTextView else { return } + let end = field.stringValue.utf16.count + editor.setSelectedRange(NSRange(location: end, length: 0)) + } + } + func controlTextDidChange(_ obj: Notification) { guard !isProgrammaticMutation else { return } guard let field = obj.object as? NSTextField else { return } @@ -294,7 +304,7 @@ private struct BrowserSearchTextFieldRepresentable: NSViewRepresentable { field.currentEditor() != nil || ((fr as? NSTextView)?.delegate as? NSTextField) === field guard !alreadyFocused else { return } - window.makeFirstResponder(field) + coordinator.focusField(field, in: window) } return field } @@ -337,7 +347,7 @@ private struct BrowserSearchTextFieldRepresentable: NSViewRepresentable { nsView.currentEditor() != nil || ((fr as? NSTextView)?.delegate as? NSTextField) === nsView guard !alreadyFocused else { return } - window.makeFirstResponder(nsView) + coordinator.focusField(nsView, in: window) } } } diff --git a/Sources/Find/SurfaceSearchOverlay.swift b/Sources/Find/SurfaceSearchOverlay.swift index f6ad9a40..49ea8f52 100644 --- a/Sources/Find/SurfaceSearchOverlay.swift +++ b/Sources/Find/SurfaceSearchOverlay.swift @@ -19,6 +19,7 @@ struct SurfaceSearchOverlay: View { let tabId: UUID let surfaceId: UUID @ObservedObject var searchState: TerminalSurface.SearchState + let canApplyFocusRequest: () -> Bool let onMoveFocusToTerminal: () -> Void let onNavigateSearch: (_ action: String) -> Void let onFieldDidFocus: () -> Void @@ -37,6 +38,7 @@ struct SurfaceSearchOverlay: View { text: $searchState.needle, isFocused: $isSearchFieldFocused, surfaceId: surfaceId, + canApplyFocusRequest: canApplyFocusRequest, onFieldDidFocus: onFieldDidFocus, onEscape: { #if DEBUG @@ -227,6 +229,7 @@ private struct SearchTextFieldRepresentable: NSViewRepresentable { @Binding var text: String @Binding var isFocused: Bool let surfaceId: UUID + let canApplyFocusRequest: () -> Bool let onFieldDidFocus: () -> Void let onEscape: () -> Void let onReturn: (_ isShift: Bool) -> Void @@ -319,6 +322,7 @@ private struct SearchTextFieldRepresentable: NSViewRepresentable { guard let field, let coordinator else { return } guard let surface = notification.object as? TerminalSurface, surface.id == coordinator.parent.surfaceId else { return } + guard coordinator.parent.canApplyFocusRequest() else { return } guard let window = field.window else { return } // Don't re-focus if already first responder. makeFirstResponder on an // already-editing NSTextField ends the editing session and restarts it @@ -370,11 +374,16 @@ private struct SearchTextFieldRepresentable: NSViewRepresentable { nsView.currentEditor() != nil || ((fr as? NSTextView)?.delegate as? NSTextField) === nsView - if isFocused, !isFirstResponder, context.coordinator.pendingFocusRequest != true { + if isFocused, + canApplyFocusRequest(), + !isFirstResponder, + context.coordinator.pendingFocusRequest != true { context.coordinator.pendingFocusRequest = true DispatchQueue.main.async { [weak nsView, weak coordinator = context.coordinator] in coordinator?.pendingFocusRequest = nil - guard let coordinator, coordinator.parent.isFocused else { return } + guard let coordinator, + coordinator.parent.isFocused, + coordinator.parent.canApplyFocusRequest() else { return } guard let nsView, let window = nsView.window else { return } let fr = window.firstResponder let alreadyFocused = fr === nsView || diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index fe39e6bf..929bc434 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -7868,6 +7868,9 @@ final class GhosttySurfaceScrollView: NSView { tabId: terminalSurface.tabId, surfaceId: terminalSurface.id, searchState: searchState, + canApplyFocusRequest: { [weak self] in + self?.canApplyMountedSearchFieldFocusRequest() ?? false + }, onMoveFocusToTerminal: { [weak self] in self?.searchFocusTarget = .terminal self?.moveFocus() @@ -7899,6 +7902,17 @@ final class GhosttySurfaceScrollView: NSView { return nil } + private func canApplyMountedSearchFieldFocusRequest() -> Bool { + guard let terminalSurface = surfaceView.terminalSurface, + let app = AppDelegate.shared, + let manager = app.tabManagerFor(tabId: terminalSurface.tabId), + manager.selectedTabId == terminalSurface.tabId, + let workspace = manager.tabs.first(where: { $0.id == terminalSurface.tabId }) else { + return false + } + return workspace.focusedPanelId == terminalSurface.id + } + private func requestMountedSearchFieldFocus( generation: UInt64, force: Bool, @@ -7906,6 +7920,7 @@ final class GhosttySurfaceScrollView: NSView { ) { guard searchOverlayMutationGeneration == generation else { return } guard force || searchFocusTarget == .searchField else { return } + guard canApplyMountedSearchFieldFocusRequest() else { return } guard let overlay = searchOverlayHostingView, overlay.superview === self, let window, diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 47d7ae1f..54372be7 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -3939,7 +3939,7 @@ extension BrowserPanel { _ = hideDeveloperTools() cancelDeveloperToolsRestoreRetry() - preferredDeveloperToolsVisible = false + setPreferredDeveloperToolsVisible(false) preferredDeveloperToolsPresentation = .unknown forceDeveloperToolsRefreshOnNextAttach = false developerToolsDetachedOpenGraceDeadline = nil @@ -4219,6 +4219,11 @@ extension BrowserPanel { } } + private func setPreferredDeveloperToolsVisible(_ next: Bool) { + guard preferredDeveloperToolsVisible != next else { return } + preferredDeveloperToolsVisible = next + } + private func syncDeveloperToolsPresentationPreferenceFromUI() { if !detachedDeveloperToolsWindows().isEmpty { setPreferredDeveloperToolsPresentation(.detached) @@ -4247,7 +4252,7 @@ extension BrowserPanel { guard self.preferredDeveloperToolsVisible else { return } guard !self.isDeveloperToolsVisible() else { return } self.developerToolsDetachedOpenGraceDeadline = nil - self.preferredDeveloperToolsVisible = false + self.setPreferredDeveloperToolsVisible(false) self.cancelDeveloperToolsRestoreRetry() #if DEBUG dlog( @@ -4383,7 +4388,7 @@ extension BrowserPanel { ) -> Bool { if isDeveloperToolsTransitionInFlight { pendingDeveloperToolsTransitionTargetVisible = targetVisible - preferredDeveloperToolsVisible = targetVisible + setPreferredDeveloperToolsVisible(targetVisible) if !targetVisible { developerToolsDetachedOpenGraceDeadline = nil forceDeveloperToolsRefreshOnNextAttach = false @@ -4410,7 +4415,7 @@ extension BrowserPanel { let isVisibleSelector = NSSelectorFromString("isVisible") let visible = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false - preferredDeveloperToolsVisible = targetVisible + setPreferredDeveloperToolsVisible(targetVisible) developerToolsTransitionTargetVisible = targetVisible if targetVisible { @@ -4512,7 +4517,7 @@ extension BrowserPanel { guard let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) else { return } if isDeveloperToolsTransitionInFlight { let targetVisible = pendingDeveloperToolsTransitionTargetVisible ?? developerToolsTransitionTargetVisible ?? visible - preferredDeveloperToolsVisible = targetVisible + setPreferredDeveloperToolsVisible(targetVisible) if targetVisible, visible { developerToolsDetachedOpenGraceDeadline = nil syncDeveloperToolsPresentationPreferenceFromUI() @@ -4527,7 +4532,7 @@ extension BrowserPanel { if visible { developerToolsDetachedOpenGraceDeadline = nil syncDeveloperToolsPresentationPreferenceFromUI() - preferredDeveloperToolsVisible = true + setPreferredDeveloperToolsVisible(true) developerToolsLastKnownVisibleAt = Date() cancelDeveloperToolsRestoreRetry() return @@ -4535,7 +4540,7 @@ extension BrowserPanel { if preserveVisibleIntent && preferredDeveloperToolsVisible { return } - preferredDeveloperToolsVisible = false + setPreferredDeveloperToolsVisible(false) developerToolsLastKnownVisibleAt = nil cancelDeveloperToolsRestoreRetry() } @@ -4590,7 +4595,7 @@ extension BrowserPanel { return false } - preferredDeveloperToolsVisible = false + setPreferredDeveloperToolsVisible(false) developerToolsDetachedOpenGraceDeadline = nil developerToolsLastKnownVisibleAt = nil forceDeveloperToolsRefreshOnNextAttach = false @@ -4636,7 +4641,7 @@ extension BrowserPanel { let detachedOpenStillSettling = developerToolsDetachedOpenGraceDeadline.map { $0 > Date() } ?? false if preferredDeveloperToolsPresentation == .detached && !detachedOpenStillSettling { - preferredDeveloperToolsVisible = false + setPreferredDeveloperToolsVisible(false) developerToolsDetachedOpenGraceDeadline = nil cancelDeveloperToolsRestoreRetry() #if DEBUG @@ -4663,7 +4668,7 @@ extension BrowserPanel { cmuxWithWindowFirstResponderBypass { _ = revealDeveloperTools(inspector) } - preferredDeveloperToolsVisible = true + setPreferredDeveloperToolsVisible(true) let visibleAfterShow = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false if visibleAfterShow { syncDeveloperToolsPresentationPreferenceFromUI() diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 9f8dd05a..0624c149 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -460,7 +460,7 @@ struct BrowserPanelView: View { searchState: searchState, focusRequestGeneration: panel.searchFocusRequestGeneration, canApplyFocusRequest: { generation in - panel.canApplySearchFocusRequest(generation) + canApplyBrowserFindFieldFocusRequest(generation) }, onNext: { panel.findNext() }, onPrevious: { panel.findPrevious() }, @@ -1133,7 +1133,7 @@ struct BrowserPanelView: View { searchState: searchState, focusRequestGeneration: panel.searchFocusRequestGeneration, canApplyFocusRequest: { generation in - panel.canApplySearchFocusRequest(generation) + canApplyBrowserFindFieldFocusRequest(generation) }, onNext: { panel.findNext() }, onPrevious: { panel.findPrevious() }, @@ -1299,6 +1299,10 @@ struct BrowserPanelView: View { return workspace.focusedPanelId == panel.id } + private func canApplyBrowserFindFieldFocusRequest(_ generation: UInt64) -> Bool { + isPanelFocusedInModel() && panel.canApplySearchFocusRequest(generation) + } + private func shouldApplyAddressBarExitFallback(in window: NSWindow) -> Bool { // Navigation-triggered omnibar blur can still be unwinding when Cmd+F opens // the browser find bar. Once find is visible, any delayed omnibar-exit diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index b8b50a82..418c0751 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -3079,17 +3079,17 @@ class TabManager: ObservableObject { guard let selectedTabId, let tab = tabs.first(where: { $0.id == selectedTabId }) else { return } - // Try to restore previous focus + let panelId: UUID if let restoredPanelId = lastFocusedPanelByTab[selectedTabId], - tab.panels[restoredPanelId] != nil, - tab.focusedPanelId != restoredPanelId { - tab.focusPanel(restoredPanelId) + tab.panels[restoredPanelId] != nil { + panelId = restoredPanelId + } else if let focusedPanelId = tab.focusedPanelId, + tab.panels[focusedPanelId] != nil { + panelId = focusedPanelId + } else { + return } - // Focus the panel - guard let panelId = tab.focusedPanelId, - let panel = tab.panels[panelId] else { return } - // 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, @@ -3101,12 +3101,9 @@ class TabManager: ObservableObject { ) } - panel.focus() - - // For terminal panels, ensure proper focus handling - if let terminalPanel = panel as? TerminalPanel { - terminalPanel.hostedView.ensureFocus(for: selectedTabId, surfaceId: panelId) - } + // Route workspace reactivation through the normal focus machinery so panel-local + // activation intents like browser find-field focus are restored on return. + tab.focusPanel(panelId) } func completePendingWorkspaceUnfocus(reason: String) { diff --git a/cmuxTests/BrowserConfigTests.swift b/cmuxTests/BrowserConfigTests.swift index a618ade1..2cb498a2 100644 --- a/cmuxTests/BrowserConfigTests.swift +++ b/cmuxTests/BrowserConfigTests.swift @@ -1,4 +1,5 @@ import XCTest +import Combine import AppKit import SwiftUI import UniformTypeIdentifiers @@ -1941,6 +1942,34 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { XCTAssertEqual(inspector.showCount, 2) } + func testSyncDoesNotRepublishHiddenDeveloperToolsIntentWhenInspectorAlreadyHidden() { + let (panel, inspector) = makePanelWithInspector(hideBehavior: .hides) + + XCTAssertTrue(panel.showDeveloperTools()) + waitForDeveloperToolsTransitions() + XCTAssertTrue(panel.isDeveloperToolsVisible()) + + inspector.hide() + XCTAssertFalse(panel.isDeveloperToolsVisible()) + + panel.syncDeveloperToolsPreferenceFromInspector() + waitForDeveloperToolsTransitions() + + var publishCount = 0 + let cancellable = panel.objectWillChange.sink { + publishCount += 1 + } + defer { _ = cancellable } + + panel.syncDeveloperToolsPreferenceFromInspector() + + XCTAssertEqual( + publishCount, + 0, + "Repeated hidden-inspector syncs should not republish the same hidden DevTools intent" + ) + } + func testForcedRefreshAfterAttachKeepsVisibleInspectorState() { let (panel, inspector) = makePanelWithInspector() diff --git a/cmuxTests/ShortcutAndCommandPaletteTests.swift b/cmuxTests/ShortcutAndCommandPaletteTests.swift index a0ab831f..07c167e6 100644 --- a/cmuxTests/ShortcutAndCommandPaletteTests.swift +++ b/cmuxTests/ShortcutAndCommandPaletteTests.swift @@ -404,6 +404,51 @@ final class CommandPaletteOpenShortcutConsumptionTests: XCTestCase { } +final class CommandPaletteFocusStealerClassificationTests: XCTestCase { + private final class NonViewTextDelegate: NSObject, NSTextViewDelegate {} + + func testTreatsGhosttySurfaceViewAsFocusStealer() { + let surfaceView = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) + + XCTAssertTrue(isCommandPaletteFocusStealingTerminalOrBrowserResponder(surfaceView)) + } + + func testTreatsTextFieldInsideTerminalHostedViewAsFocusStealer() { + let hostedView = GhosttySurfaceScrollView( + surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) + ) + let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 120, height: 24)) + hostedView.addSubview(textField) + + XCTAssertTrue( + isCommandPaletteFocusStealingTerminalOrBrowserResponder(textField), + "Terminal-owned overlay text inputs should not be allowed to reclaim focus from the command palette" + ) + } + + func testDoesNotTreatUnrelatedTextFieldAsFocusStealer() { + let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 120, height: 24)) + + XCTAssertFalse(isCommandPaletteFocusStealingTerminalOrBrowserResponder(textField)) + } + + func testTreatsTextViewInsideTerminalHostedViewAsFocusStealerWhenDelegateIsNotAView() { + let hostedView = GhosttySurfaceScrollView( + surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) + ) + let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: 120, height: 24)) + let delegate = NonViewTextDelegate() + textView.delegate = delegate + hostedView.addSubview(textView) + + XCTAssertTrue( + isCommandPaletteFocusStealingTerminalOrBrowserResponder(textView), + "NSTextView responders should still be blocked via the NSView hierarchy walk when the delegate is not a view" + ) + } +} + + final class CommandPaletteRestoreFocusStateMachineTests: XCTestCase { func testRestoresBrowserAddressBarWhenPaletteOpenedFromFocusedAddressBar() { let panelId = UUID() diff --git a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift index f10d7a5f..873f6b16 100644 --- a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift +++ b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift @@ -853,11 +853,118 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { ) } + func testBrowserFindFieldKeepsFocusAfterNewWorkspaceRoundTrip() { + let app = XCUIApplication() + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_RECORD_ONLY"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath + launchAndEnsureForeground(app) + + let window = app.windows.firstMatch + _ = window.waitForExistence(timeout: 2.0) + + app.typeKey("d", modifierFlags: [.command]) + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + guard data["lastSplitDirection"] == "right" else { return false } + guard let paneCountAfterSplit = Int(data["paneCountAfterSplit"] ?? "") else { return false } + return paneCountAfterSplit >= 2 + }, + "Expected Cmd+D to create a split before opening the browser. data=\(String(describing: loadData()))" + ) + + app.typeKey("l", modifierFlags: [.command]) + + let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch + XCTAssertTrue(omnibar.waitForExistence(timeout: 8.0), "Expected browser omnibar after Cmd+L") + + app.typeKey("a", modifierFlags: [.command]) + app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: []) + app.typeText("example.com") + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) + + XCTAssertTrue( + waitForOmnibarToContainExampleDomain(omnibar, timeout: 8.0), + "Expected browser navigation to example domain before opening find. value=\(String(describing: omnibar.value))" + ) + + app.typeKey("f", modifierFlags: [.command]) + + let findField = app.textFields["BrowserFindSearchTextField"].firstMatch + XCTAssertTrue(findField.waitForExistence(timeout: 6.0), "Expected browser find field after Cmd+F") + + app.typeText("seed") + XCTAssertTrue( + waitForCondition(timeout: 4.0) { + ((findField.value as? String) ?? "") == "seed" + }, + "Expected browser find field to capture initial typing. value=\(String(describing: findField.value))" + ) + + app.typeKey("p", modifierFlags: [.command, .shift]) + + let paletteSearchField = app.textFields["CommandPaletteSearchField"].firstMatch + XCTAssertTrue(paletteSearchField.waitForExistence(timeout: 5.0), "Expected command palette search field") + paletteSearchField.click() + paletteSearchField.typeText("New Workspace") + + let firstResultRow = app.descendants(matching: .any).matching(identifier: "CommandPaletteResultRow.0").firstMatch + XCTAssertTrue(firstResultRow.waitForExistence(timeout: 5.0), "Expected command palette results for New Workspace") + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) + + XCTAssertTrue( + waitForNonExistence(paletteSearchField, timeout: 5.0), + "Expected command palette to dismiss after creating a workspace" + ) + + app.typeKey("1", modifierFlags: [.command]) + + let restoredFindField = app.textFields["BrowserFindSearchTextField"].firstMatch + XCTAssertTrue(restoredFindField.waitForExistence(timeout: 6.0), "Expected browser find field after returning to workspace 1") + XCTAssertTrue( + waitForCondition(timeout: 4.0) { + ((restoredFindField.value as? String) ?? "") == "seed" + }, + "Expected existing browser find query to persist after returning. value=\(String(describing: restoredFindField.value))" + ) + + app.typeText("x") + XCTAssertTrue( + waitForCondition(timeout: 4.0) { + ((restoredFindField.value as? String) ?? "") == "seedx" + }, + "Expected typing after returning from a new workspace to stay in the browser find field. " + + "findValue=\(String(describing: restoredFindField.value)) omnibarValue=\(String(describing: omnibar.value))" + ) + } + + func testWorkspaceRoundTripPreservesFocusedTerminalFindWhenBrowserFindIsAlsoOpen() { + runSplitFindWorkspaceRoundTripScenario(restoredOwner: .terminal) + } + + func testWorkspaceRoundTripPreservesFocusedBrowserFindWhenTerminalFindIsAlsoOpen() { + runSplitFindWorkspaceRoundTripScenario(restoredOwner: .browser) + } + private enum FindFocusRoute { case cmdOptionArrows case cmdCtrlLetters } + private enum SplitFindOwner { + case terminal + case browser + + var focusedPanelKind: String { + switch self { + case .terminal: + return "terminal" + case .browser: + return "browser" + } + } + } + private func runFindFocusPersistenceScenario(route: FindFocusRoute, useAutofocusRacePage: Bool) { let app = XCUIApplication() app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath @@ -967,6 +1074,124 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { ) } + private func runSplitFindWorkspaceRoundTripScenario(restoredOwner: SplitFindOwner) { + let app = XCUIApplication() + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_RECORD_ONLY"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath + launchAndEnsureForeground(app) + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 10.0), "Expected main window to exist") + + app.typeKey("d", modifierFlags: [.command]) + focusRightPaneForFindScenario(app, route: .cmdOptionArrows) + + app.typeKey("l", modifierFlags: [.command, .shift]) + let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch + XCTAssertTrue(omnibar.waitForExistence(timeout: 8.0), "Expected browser omnibar after Cmd+Shift+L") + + app.typeKey("a", modifierFlags: [.command]) + app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: []) + app.typeText("example.com") + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) + + XCTAssertTrue( + waitForOmnibarToContainExampleDomain(omnibar, timeout: 8.0), + "Expected browser navigation to example domain before running workspace round trip. value=\(String(describing: omnibar.value))" + ) + + focusLeftPaneForFindScenario(app, route: .cmdOptionArrows) + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["focusedPanelKind"] == "terminal" + }, + "Expected left terminal pane to be focused before opening terminal find. data=\(String(describing: loadData()))" + ) + app.typeKey("f", modifierFlags: [.command]) + app.typeText("la") + + focusRightPaneForFindScenario(app, route: .cmdOptionArrows) + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["focusedPanelKind"] == "browser" + && data["terminalFindNeedle"] == "la" + }, + "Expected terminal find query to persist before opening browser find. data=\(String(describing: loadData()))" + ) + app.typeKey("f", modifierFlags: [.command]) + app.typeText("am") + + switch restoredOwner { + case .terminal: + focusLeftPaneForFindScenario(app, route: .cmdOptionArrows) + case .browser: + break + } + + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["focusedPanelKind"] == restoredOwner.focusedPanelKind + && data["terminalFindNeedle"] == "la" + && data["browserFindNeedle"] == "am" + }, + "Expected the intended find owner before leaving workspace 1. data=\(String(describing: loadData()))" + ) + + openCommandPaletteForNewWorkspace(app) + app.typeKey("1", modifierFlags: [.command]) + + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["focusedPanelKind"] == restoredOwner.focusedPanelKind + && data["terminalFindNeedle"] == "la" + && data["browserFindNeedle"] == "am" + }, + "Expected the previously focused find owner to be restored after the workspace round trip. data=\(String(describing: loadData()))" + ) + + switch restoredOwner { + case .terminal: + app.typeText("foo") + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["focusedPanelKind"] == "terminal" + && data["terminalFindNeedle"] == "lafoo" + && data["browserFindNeedle"] == "am" + }, + "Expected typing after returning to stay in terminal find. data=\(String(describing: loadData()))" + ) + case .browser: + app.typeText("do") + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["focusedPanelKind"] == "browser" + && data["terminalFindNeedle"] == "la" + && data["browserFindNeedle"] == "amdo" + }, + "Expected typing after returning to stay in browser find. data=\(String(describing: loadData()))" + ) + } + } + + private func openCommandPaletteForNewWorkspace(_ app: XCUIApplication) { + app.typeKey("p", modifierFlags: [.command, .shift]) + + let paletteSearchField = app.textFields["CommandPaletteSearchField"].firstMatch + XCTAssertTrue(paletteSearchField.waitForExistence(timeout: 5.0), "Expected command palette search field") + paletteSearchField.click() + paletteSearchField.typeText("New Workspace") + + let firstResultRow = app.descendants(matching: .any).matching(identifier: "CommandPaletteResultRow.0").firstMatch + XCTAssertTrue(firstResultRow.waitForExistence(timeout: 5.0), "Expected command palette results for New Workspace") + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) + + XCTAssertTrue( + waitForNonExistence(paletteSearchField, timeout: 5.0), + "Expected command palette to dismiss after creating a workspace" + ) + } + private func focusLeftPaneForFindScenario(_ app: XCUIApplication, route: FindFocusRoute) { switch route { case .cmdOptionArrows: