diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index ba5303db1e9a..0f4e62ade6c2 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -163,6 +163,7 @@ CA39C0304FE351A21C372429 /* SidebarWidthPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */; }; CB23911D7E131E8FBC9B82B6 /* SidebarOrderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */; }; D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */; }; + D0E0F0B4A1B2C3D4E5F60718 /* FindSelectionShortcutUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B5A1B2C3D4E5F60718 /* FindSelectionShortcutUITests.swift */; }; D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */; }; D1320AA0D1320AA0D1320AA1 /* AppIconDockTilePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */; }; D1320AA0D1320AA0D1320AA2 /* CmuxDockTilePlugin.plugin in Copy Dock Tile Plugin */ = {isa = PBXBuildFile; fileRef = D1320AA0D1320AA0D1320AA5 /* CmuxDockTilePlugin.plugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -426,6 +427,7 @@ C1ADE00001A1B2C3D4E5F719 /* claude */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = Resources/bin/claude; sourceTree = SOURCE_ROOT; }; C2577001A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCmdClickUITests.swift; sourceTree = ""; }; D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = ""; }; + D0E0F0B5A1B2C3D4E5F60718 /* FindSelectionShortcutUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FindSelectionShortcutUITests.swift; sourceTree = ""; }; D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarSuggestionsUITests.swift; sourceTree = ""; }; D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconDockTilePlugin.swift; sourceTree = ""; }; D1320AA0D1320AA0D1320AA5 /* CmuxDockTilePlugin.plugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CmuxDockTilePlugin.plugin; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -568,6 +570,7 @@ C2577001A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift */, E6FA9085A1B2C3D4E5F60718 /* WorkspaceDescriptionUITests.swift */, D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */, + D0E0F0B5A1B2C3D4E5F60718 /* FindSelectionShortcutUITests.swift */, D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */, FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */, C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */, @@ -1161,6 +1164,7 @@ C2577000A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift in Sources */, E6FA9084A1B2C3D4E5F60718 /* WorkspaceDescriptionUITests.swift in Sources */, D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */, + D0E0F0B4A1B2C3D4E5F60718 /* FindSelectionShortcutUITests.swift in Sources */, D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */, FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */, C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */, diff --git a/Sources/Find/BrowserSearchOverlay.swift b/Sources/Find/BrowserSearchOverlay.swift index 6a25ba987c48..54097fa89e42 100644 --- a/Sources/Find/BrowserSearchOverlay.swift +++ b/Sources/Find/BrowserSearchOverlay.swift @@ -222,13 +222,19 @@ private struct BrowserSearchTextFieldRepresentable: NSViewRepresentable { } } - func focusField(_ field: BrowserSearchNativeTextField, in window: NSWindow) { - guard window.makeFirstResponder(field) else { return } + func focusField(_ field: BrowserSearchNativeTextField, in window: NSWindow, selectAll: Bool) { + let alreadyFocused = cmuxTextFieldIsFirstResponder(field, in: window) + guard alreadyFocused || 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)) + guard !editor.hasMarkedText() else { return } + if selectAll { + editor.setSelectedRange(NSRange(location: 0, length: editor.string.utf16.count)) + } else if !alreadyFocused { + let end = field.stringValue.utf16.count + editor.setSelectedRange(NSRange(location: end, length: 0)) + } } } @@ -299,12 +305,10 @@ private struct BrowserSearchTextFieldRepresentable: NSViewRepresentable { notifiedPanelId == coordinator.parent.panelId else { return } guard coordinator.parent.canApplyFocusRequest(coordinator.parent.focusRequestGeneration) else { return } guard let window = field.window else { return } - let fr = window.firstResponder - let alreadyFocused = fr === field || - field.currentEditor() != nil || - ((fr as? NSTextView)?.delegate as? NSTextField) === field - guard !alreadyFocused else { return } - coordinator.focusField(field, in: window) + let selectAll = notification.userInfo?[FindFocusNotificationKey.selectAll] as? Bool == true + let alreadyFocused = cmuxTextFieldIsFirstResponder(field, in: window) + guard !alreadyFocused || selectAll else { return } + coordinator.focusField(field, in: window, selectAll: selectAll) } return field } @@ -325,11 +329,7 @@ private struct BrowserSearchTextFieldRepresentable: NSViewRepresentable { } if let window = nsView.window { - let fr = window.firstResponder - let isFirstResponder = - fr === nsView || - nsView.currentEditor() != nil || - ((fr as? NSTextView)?.delegate as? NSTextField) === nsView + let isFirstResponder = cmuxTextFieldIsFirstResponder(nsView, in: window) if isFocused, canApplyFocusRequest(focusRequestGeneration), @@ -342,12 +342,9 @@ private struct BrowserSearchTextFieldRepresentable: NSViewRepresentable { coordinator.parent.isFocused, coordinator.parent.canApplyFocusRequest(coordinator.parent.focusRequestGeneration) else { return } guard let nsView, let window = nsView.window else { return } - let fr = window.firstResponder - let alreadyFocused = fr === nsView || - nsView.currentEditor() != nil || - ((fr as? NSTextView)?.delegate as? NSTextField) === nsView + let alreadyFocused = cmuxTextFieldIsFirstResponder(nsView, in: window) guard !alreadyFocused else { return } - coordinator.focusField(nsView, in: window) + coordinator.focusField(nsView, in: window, selectAll: false) } } } diff --git a/Sources/Find/SurfaceSearchOverlay.swift b/Sources/Find/SurfaceSearchOverlay.swift index 2e602ef79c63..e1c5be6d9807 100644 --- a/Sources/Find/SurfaceSearchOverlay.swift +++ b/Sources/Find/SurfaceSearchOverlay.swift @@ -2,6 +2,17 @@ import AppKit import Bonsplit import SwiftUI +enum FindFocusNotificationKey { + static let selectAll = "cmux.find.selectAll" +} + +func cmuxTextFieldIsFirstResponder(_ field: NSTextField, in window: NSWindow) -> Bool { + let fr = window.firstResponder + return fr === field || + field.currentEditor() != nil || + (fr as? NSTextView).flatMap { cmuxFieldEditorOwnerView($0) } === field +} + private extension NSView { func cmuxAncestor(of type: T.Type) -> T? { var current: NSView? = self @@ -251,6 +262,18 @@ private struct SearchTextFieldRepresentable: NSViewRepresentable { } } + func focusField(_ field: SearchNativeTextField, in window: NSWindow, selectAll: Bool) { + let alreadyFocused = cmuxTextFieldIsFirstResponder(field, in: window) + guard alreadyFocused || window.makeFirstResponder(field) else { return } + guard selectAll else { return } + DispatchQueue.main.async { [weak field] in + guard let field, + let editor = field.currentEditor() as? NSTextView else { return } + guard !editor.hasMarkedText() else { return } + editor.setSelectedRange(NSRange(location: 0, length: editor.string.utf16.count)) + } + } + func controlTextDidChange(_ obj: Notification) { guard !isProgrammaticMutation else { return } guard let field = obj.object as? NSTextField else { return } @@ -324,25 +347,23 @@ private struct SearchTextFieldRepresentable: NSViewRepresentable { surface.id == coordinator.parent.surfaceId else { return } guard coordinator.parent.canApplyFocusRequest() else { return } guard let window = field.window else { return } + let selectAll = notification.userInfo?[FindFocusNotificationKey.selectAll] as? Bool == true // Don't re-focus if already first responder. makeFirstResponder on an // already-editing NSTextField ends the editing session and restarts it // with all text selected, causing typed characters to replace each other. - let fr = window.firstResponder - let alreadyFocused = fr === field || - field.currentEditor() != nil || - ((fr as? NSTextView)?.delegate as? NSTextField) === field + let alreadyFocused = cmuxTextFieldIsFirstResponder(field, in: window) #if DEBUG cmuxDebugLog( "find.nativeField.searchFocusNotification surface=\(coordinator.parent.surfaceId.uuidString.prefix(5)) " + - "alreadyFocused=\(alreadyFocused) firstResponder=\(String(describing: fr))" + "alreadyFocused=\(alreadyFocused) firstResponder=\(String(describing: window.firstResponder))" ) #endif - guard !alreadyFocused else { return } - let result = window.makeFirstResponder(field) + guard !alreadyFocused || selectAll else { return } + coordinator.focusField(field, in: window, selectAll: selectAll) #if DEBUG cmuxDebugLog( "find.nativeField.searchFocusApply surface=\(coordinator.parent.surfaceId.uuidString.prefix(5)) " + - "result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))" + "selectAll=\(selectAll ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))" ) #endif } @@ -368,11 +389,7 @@ private struct SearchTextFieldRepresentable: NSViewRepresentable { // Sync focus from binding to AppKit if let window = nsView.window { - let fr = window.firstResponder - let isFirstResponder = - fr === nsView || - nsView.currentEditor() != nil || - ((fr as? NSTextView)?.delegate as? NSTextField) === nsView + let isFirstResponder = cmuxTextFieldIsFirstResponder(nsView, in: window) if isFocused, canApplyFocusRequest(), @@ -385,12 +402,9 @@ private struct SearchTextFieldRepresentable: NSViewRepresentable { 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 || - nsView.currentEditor() != nil || - ((fr as? NSTextView)?.delegate as? NSTextField) === nsView + let alreadyFocused = cmuxTextFieldIsFirstResponder(nsView, in: window) guard !alreadyFocused else { return } - window.makeFirstResponder(nsView) + coordinator.focusField(nsView, in: window, selectAll: false) } } } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index a13db8ad219b..0d49c74ffdff 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -5031,18 +5031,18 @@ extension BrowserPanel { "firstResponder=\(String(describing: window?.firstResponder))" ) #endif - postBrowserSearchFocusNotification(reason: "immediate", generation: generation) + postBrowserSearchFocusNotification(reason: "immediate", generation: generation, selectAll: !created) // Focus notification can race with portal overlay mount. Re-post on the // next runloop and shortly after so the find field can claim first responder. DispatchQueue.main.async { [weak self] in - self?.postBrowserSearchFocusNotification(reason: "async0", generation: generation) + self?.postBrowserSearchFocusNotification(reason: "async0", generation: generation, selectAll: !created) } DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in - self?.postBrowserSearchFocusNotification(reason: "async50ms", generation: generation) + self?.postBrowserSearchFocusNotification(reason: "async50ms", generation: generation, selectAll: false) } } - private func postBrowserSearchFocusNotification(reason: String, generation: UInt64) { + private func postBrowserSearchFocusNotification(reason: String, generation: UInt64, selectAll: Bool) { guard canApplySearchFocusRequest(generation) else { #if DEBUG cmuxDebugLog( @@ -5057,11 +5057,11 @@ extension BrowserPanel { cmuxDebugLog( "browser.find.focusNotification panel=\(id.uuidString.prefix(5)) " + "generation=\(generation) " + - "reason=\(reason) window=\(window?.windowNumber ?? -1) " + + "reason=\(reason) selectAll=\(selectAll ? 1 : 0) window=\(window?.windowNumber ?? -1) " + "firstResponder=\(String(describing: window?.firstResponder))" ) #endif - NotificationCenter.default.post(name: .browserSearchFocus, object: id) + NotificationCenter.default.post(name: .browserSearchFocus, object: id, userInfo: [FindFocusNotificationKey.selectAll: selectAll]) } func findNext() { @@ -5092,10 +5092,7 @@ extension BrowserPanel { if replaySearch, !state.needle.isEmpty { executeFindSearch(state.needle) } - postBrowserSearchFocusNotification( - reason: "restoreAfterNavigation", - generation: searchFocusRequestGeneration - ) + postBrowserSearchFocusNotification(reason: "restoreAfterNavigation", generation: searchFocusRequestGeneration, selectAll: false) } private func executeFindSearch(_ needle: String) { diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index bcd4c201a42d..85eea709eb93 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1917,19 +1917,16 @@ class TabManager: ObservableObject { } func startSearch() { - if let panel = selectedTerminalPanel { - if panel.searchState == nil { - panel.searchState = TerminalSurface.SearchState() - } - NSLog("Find: startSearch workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString) - NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface) - _ = panel.performBindingAction("start_search") - return - } if let panel = selectedTerminalPanel { let hadExistingSearch = panel.searchState != nil - let handled = startOrFocusTerminalSearch(panel.surface) NSLog("Find: startSearch workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString) + let handled = startOrFocusTerminalSearch(panel.surface) { surface in + NotificationCenter.default.post( + name: .ghosttySearchFocus, + object: surface, + userInfo: [FindFocusNotificationKey.selectAll: hadExistingSearch] + ) + } #if DEBUG cmuxDebugLog( "find.startSearch workspace=\(panel.workspaceId.uuidString.prefix(5)) " + diff --git a/cmuxUITests/FindSelectionShortcutUITests.swift b/cmuxUITests/FindSelectionShortcutUITests.swift new file mode 100644 index 000000000000..e1ba63571fdc --- /dev/null +++ b/cmuxUITests/FindSelectionShortcutUITests.swift @@ -0,0 +1,138 @@ +import XCTest +import Foundation + +final class FindSelectionShortcutUITests: XCTestCase { + private var dataPath = "" + private var socketPath = "" + + override func setUp() { + super.setUp() + continueAfterFailure = false + dataPath = "/tmp/cmux-ui-test-find-selection-\(UUID().uuidString).json" + try? FileManager.default.removeItem(atPath: dataPath) + socketPath = "/tmp/cmux-ui-test-socket-\(UUID().uuidString).sock" + try? FileManager.default.removeItem(atPath: socketPath) + } + + func testRepeatedCmdFSelectsExistingTerminalAndBrowserFindText() { + 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) + + XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 10.0), "Expected main window") + + 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 split before opening browser. data=\(String(describing: loadData()))" + ) + openBrowserInRightPane(app) + assertFindReplacement(app, pane: .terminal, initial: "terminal", replacement: "x") + assertFindReplacement(app, pane: .browser, initial: "browser", replacement: "y") + } + + private enum Pane { + case terminal + case browser + + var opposite: Pane { self == .terminal ? .browser : .terminal } + var focusKey: String { self == .terminal ? "terminal" : "browser" } + var needleKey: String { self == .terminal ? "terminalFindNeedle" : "browserFindNeedle" } + var replacementMessage: String { self == .terminal ? "terminal find text" : "browser find text" } + var arrowKey: String { + self == .terminal ? XCUIKeyboardKey.leftArrow.rawValue : XCUIKeyboardKey.rightArrow.rawValue + } + } + + private func openBrowserInRightPane(_ app: XCUIApplication) { + app.typeKey(XCUIKeyboardKey.rightArrow.rawValue, modifierFlags: [.command, .option]) + app.typeKey("l", modifierFlags: [.command, .shift]) + let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch + XCTAssertTrue(omnibar.waitForExistence(timeout: 8.0), "Expected browser omnibar") + 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") + } + + private func assertFindReplacement(_ app: XCUIApplication, pane: Pane, initial: String, replacement: String) { + focusPane(pane, app: app) + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { $0["focusedPanelKind"] == pane.focusKey }, + "Expected \(pane.focusKey) focus. data=\(String(describing: loadData()))" + ) + app.typeKey("f", modifierFlags: [.command]) + app.typeText(initial) + focusPane(pane.opposite, app: app) + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["focusedPanelKind"] == pane.opposite.focusKey && data[pane.needleKey] == initial + }, + "Expected initial \(pane.replacementMessage). data=\(String(describing: loadData()))" + ) + focusPane(pane, app: app) + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { $0["focusedPanelKind"] == pane.focusKey }, + "Expected \(pane.focusKey) focus before repeated Cmd+F. data=\(String(describing: loadData()))" + ) + app.typeKey("f", modifierFlags: [.command]) + app.typeText(replacement) + focusPane(pane.opposite, app: app) + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["focusedPanelKind"] == pane.opposite.focusKey && data[pane.needleKey] == replacement + }, + "Expected repeated Cmd+F to replace \(pane.replacementMessage). data=\(String(describing: loadData()))" + ) + } + + private func focusPane(_ pane: Pane, app: XCUIApplication) { + app.typeKey(pane.arrowKey, modifierFlags: [.command, .option]) + } + + private func waitForOmnibarToContainExampleDomain(_ omnibar: XCUIElement, timeout: TimeInterval) -> Bool { + waitForCondition(timeout: timeout) { + let value = (omnibar.value as? String) ?? "" + return value.contains("example.com") || value.contains("example.org") + } + } + + private func launchAndEnsureForeground(_ app: XCUIApplication) { + let options = XCTExpectedFailure.Options() + options.isStrict = false + XCTExpectFailure("App activation may fail on headless CI runners", options: options) { + app.launch() + } + + if app.state == .runningForeground || app.state == .runningBackground { return } + XCTFail("App failed to start. state=\(app.state.rawValue)") + } + + private func waitForDataMatch(timeout: TimeInterval, predicate: @escaping ([String: String]) -> Bool) -> Bool { + waitForCondition(timeout: timeout) { + guard let data = self.loadData() else { return false } + return predicate(data) + } + } + + private func loadData() -> [String: String]? { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: dataPath)) else { return nil } + return (try? JSONSerialization.jsonObject(with: data)) as? [String: String] + } + + private func waitForCondition(timeout: TimeInterval, predicate: @escaping () -> Bool) -> Bool { + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in predicate() }, + object: nil + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed + } + +}