Fix command palette focus after terminal find (#2089)

* test: cover command palette focus guard

* fix: block terminal find from stealing palette focus

* test: cover text view focus-stealer fallback

* Add regression for hidden DevTools sync republish loop

* Avoid redundant DevTools visibility publishes

* test: cover browser find focus after workspace round-trip

* fix: restore browser find focus after workspace round-trip

* fix: keep browser find caret on workspace return

* Add workspace round-trip split find regressions

* Keep inactive find overlays from stealing focus

---------

Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
This commit is contained in:
Lawrence Chen 2026-03-25 17:27:54 -07:00 committed by GitHub
parent b42f64fbe3
commit 8a3ab6b3f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 402 additions and 59 deletions

View file

@ -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 {

View file

@ -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)
}
}
}

View file

@ -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 ||

View file

@ -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,

View file

@ -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()

View file

@ -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

View file

@ -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) {

View file

@ -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()

View file

@ -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()

View file

@ -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: