macOS: use find pasteboard for search needle (#12712)

Fixes the issue described in #12516.

### What
- Inject an `OSPasteboard` into `SearchState`
- Add `OSPasteboard` extension to normalize working with strings between
UIPasteboard/NSPasteboard
- Add `BackportSelectionTextField` which supports text selection for
MacOS 15/iOS 18 and up.
- Read from the pasteboard when the overlay opens and when the app
becomes active
- Write to the pasteboard when the search needle changes
- Annotate `SearchState` as MainActor. `NSPasteboard` isn't thread safe,
and since `SearchState` is already accessed from the main thread,
MainActor enforces our writes be thread safe
- Add SearchState unit tests

### Why
Consistent with other macOS apps, the Find bar's search needle should
persist when re-opened and should sync to the Find bar in other apps.
For example, see Xcode, Notes, Terminal, and Safari.


https://github.com/user-attachments/assets/b6a55a4a-a52c-45bc-ac38-c9df452c11cb
This commit is contained in:
Mitchell Hashimoto
2026-05-22 08:57:45 -07:00
committed by GitHub
7 changed files with 224 additions and 4 deletions

View File

@@ -109,6 +109,7 @@
Helpers/CrossKit.swift,
"Helpers/Extensions/NSImage+Extension.swift",
"Helpers/Extensions/OSColor+Extension.swift",
"Helpers/Extensions/OSPasteboard+Extension.swift",
);
target = 8193244C2F24E6C000A9ED8F /* DockTilePlugin */;
};

View File

@@ -1,5 +1,6 @@
import Foundation
import GhosttyKit
import SwiftUI
extension Ghostty {
class OSSurfaceView: OSView, ObservableObject {
@@ -115,13 +116,42 @@ extension Ghostty {
// MARK: Search State
extension Ghostty.OSSurfaceView {
class SearchState: ObservableObject {
@MainActor class SearchState: ObservableObject {
/// The pasteboard used to persist the search needle.
///
/// The `.find` pasteboard lets us sync our needle across the system and other find bars.
private let pasteboard: OSPasteboard
@Published var needle: String = ""
@Published var selected: UInt?
@Published var total: UInt?
init(from startSearch: Ghostty.Action.StartSearch) {
self.needle = startSearch.needle ?? ""
/// The range of the needle's text selection in the find bar.
@Published var needleSelection: Range<String.Index>?
init(
from startSearch: Ghostty.Action.StartSearch,
pasteboard: OSPasteboard = OSPasteboard.find
) {
self.pasteboard = pasteboard
if let needle = startSearch.needle, !needle.isEmpty {
self.needle = needle
writePasteboardNeedle()
} else {
readPasteboardNeedle()
}
}
func readPasteboardNeedle() {
let pasteboardNeedle = pasteboard.string
if let pasteboardNeedle, pasteboardNeedle != needle {
needle = pasteboardNeedle
needleSelection = needle.startIndex..<needle.endIndex
}
}
func writePasteboardNeedle() {
pasteboard.string = needle
}
}

View File

@@ -377,7 +377,11 @@ extension Ghostty {
var body: some View {
GeometryReader { geo in
HStack(spacing: 4) {
TextField("Search", text: $searchState.needle)
BackportSelectionTextField(
"Search",
text: $searchState.needle,
selection: $searchState.needleSelection
)
.textFieldStyle(.plain)
.frame(width: 180)
.padding(.leading, 8)
@@ -401,6 +405,18 @@ extension Ghostty {
.padding(.trailing, 8)
}
}
.onChange(of: searchState.needle) { _ in
searchState.writePasteboardNeedle()
}
.onReceive(
NotificationCenter.default.publisher(
for: OSApplication.didBecomeActiveNotification
)
) { _ in
// When the app becomes active, we want to check for external changes
// to our synced needle.
searchState.readPasteboardNeedle()
}
#if canImport(AppKit)
.onExitCommand {
if searchState.needle.isEmpty {

View File

@@ -131,3 +131,49 @@ enum BackportNSGlassStyle {
}
#endif
}
/// Backported `TextField` that supports text selection on macOS 15/iOS 18 and up. The `selection`
/// has no effect on versions below macOS 15/iOS 18.
struct BackportSelectionTextField: View {
private let titleKey: LocalizedStringKey
@Binding private var text: String
@Binding private var textSelection: Range<String.Index>?
init(
_ titleKey: LocalizedStringKey,
text: Binding<String>,
selection: Binding<Range<String.Index>?>
) {
self.titleKey = titleKey
self._text = text
self._textSelection = selection
}
var body: some View {
if #available(iOS 18.0, macOS 15, *) {
TextField(
titleKey,
text: _text,
selection: Binding(
get: {
if let textSelection {
TextSelection(range: textSelection)
} else {
nil
}
},
set: { selection in
if let selection,
case .selection(let range) = selection.indices {
self.textSelection = range
} else {
self.textSelection = nil
}
}
)
)
} else {
TextField(titleKey, text: _text)
}
}
}

View File

@@ -11,6 +11,7 @@ typealias OSView = NSView
typealias OSColor = NSColor
typealias OSSize = NSSize
typealias OSPasteboard = NSPasteboard
typealias OSApplication = NSApplication
protocol OSViewRepresentable: NSViewRepresentable where NSViewType == OSViewType {
associatedtype OSViewType: NSView
@@ -36,6 +37,7 @@ typealias OSView = UIView
typealias OSColor = UIColor
typealias OSSize = CGSize
typealias OSPasteboard = UIPasteboard
typealias OSApplication = UIApplication
protocol OSViewRepresentable: UIViewRepresentable {
associatedtype OSViewType: UIView

View File

@@ -0,0 +1,28 @@
#if canImport(AppKit)
/// Normalizes the interface between NSPasteboard and UIPasteboard for working with pasteboard
/// strings.
extension OSPasteboard {
@MainActor static let find = OSPasteboard(name: .find)
/// The pasteboard's current string value.
@MainActor var string: String? {
get {
string(forType: .string)
}
set {
clearContents()
if let newValue {
setString(newValue, forType: .string)
}
}
}
}
#elseif canImport(UIKit)
extension OSPasteboard {
static let find = OSPasteboard.withUniqueName()
}
#endif

View File

@@ -0,0 +1,97 @@
import AppKit
import GhosttyKit
import Testing
@testable import Ghostty
@MainActor struct SurfaceView_SearchStateTests {
typealias SearchState = Ghostty.OSSurfaceView.SearchState
typealias StartSearch = Ghostty.Action.StartSearch
/// A unique pasteboard for each test case prevents flakiness.
let pasteboard = OSPasteboard.withUniqueName()
init() {
pasteboard.setString("pb", forType: .string)
}
@Test func init_withNilNeedle_readsPasteboardNeedle() {
let sut = SearchState(
from: StartSearch(c: .init(needle: nil)),
pasteboard: pasteboard
)
#expect(sut.needle == "pb")
}
@Test func init_withEmptyNeedle_readsPasteboardNeedle() {
"".withCString { needle in
let sut = SearchState(
from: StartSearch(c: .init(needle: needle)),
pasteboard: pasteboard
)
#expect(sut.needle == "pb")
}
}
@Test func init_withNeedle_setsNeedle() {
"start".withCString { needle in
let sut = SearchState(
from: StartSearch(c: .init(needle: needle)),
pasteboard: pasteboard
)
#expect(sut.needle == "start")
}
}
@Test func init_withNeedle_writesPasteboard() {
"start".withCString { needle in
_ = SearchState(
from: StartSearch(c: .init(needle: needle)),
pasteboard: pasteboard
)
#expect(pasteboard.string(forType: .string) == "start")
}
}
@Test func writePasteboardNeedle_writesPasteboard() {
let sut = SearchState(
from: StartSearch(c: .init(needle: nil)),
pasteboard: pasteboard
)
sut.needle = "sut"
sut.writePasteboardNeedle()
#expect(pasteboard.string(forType: .string) == "sut")
}
@Test func readPasteboardNeedle_whenPasteboardNeedleIsNil() {
let sut = SearchState(
from: StartSearch(c: .init(needle: nil)),
pasteboard: pasteboard
)
pasteboard.clearContents()
sut.needle = "sut"
sut.readPasteboardNeedle()
#expect(sut.needle == "sut")
}
@Test func readPasteboardNeedle_whenPasteboardNeedleIsValid() {
let sut = SearchState(
from: StartSearch(c: .init(needle: nil)),
pasteboard: pasteboard
)
sut.needle = "sut"
sut.readPasteboardNeedle()
#expect(sut.needle == "pb")
}
@Test func readPasteboardNeedle_setsNeedleSelectionRange() {
let sut = SearchState(
from: StartSearch(c: .init(needle: nil)),
pasteboard: pasteboard
)
sut.needle = "sut"
sut.readPasteboardNeedle()
let expected = "pb".startIndex..<"pb".endIndex
#expect(sut.needleSelection == expected)
}
}