macos: prevent stale search selection crash (#13645)

Fixes #13266

Keep search text and its selection range synchronized as a single state
transition.

Deleting or replacing a search term could leave a String.Index range
from the old value attached to the text field. Applying that range could
crash the app.

Clear selection before publishing new text.
This commit is contained in:
Mitchell Hashimoto
2026-08-05 14:55:37 -07:00
committed by GitHub
4 changed files with 75 additions and 12 deletions

View File

@@ -2164,7 +2164,7 @@ extension Ghostty {
DispatchQueue.main.async {
if let searchState = surfaceView.searchState {
if let needle = startSearch.needle, !needle.isEmpty {
searchState.needle = needle
searchState.setNeedle(needle)
}
} else {
surfaceView.searchState = Ghostty.SurfaceView.SearchState(from: startSearch)

View File

@@ -122,12 +122,12 @@ extension Ghostty.OSSurfaceView {
/// The `.find` pasteboard lets us sync our needle across the system and other find bars.
private let pasteboard: OSPasteboard
@Published var needle: String = ""
@Published private(set) var needle: String = ""
@Published var selected: UInt?
@Published var total: UInt?
/// The range of the needle's text selection in the find bar.
@Published var needleSelection: Range<String.Index>?
@Published private(set) var needleSelection: Range<String.Index>?
init(
from startSearch: Ghostty.Action.StartSearch,
@@ -135,18 +135,49 @@ extension Ghostty.OSSurfaceView {
) {
self.pasteboard = pasteboard
if let needle = startSearch.needle, !needle.isEmpty {
self.needle = needle
setNeedle(needle)
writePasteboardNeedle()
} else {
readPasteboardNeedle()
}
}
/// Replaces the search needle while keeping its selection valid.
func setNeedle(_ needle: String, selectAll: Bool = false) {
if needle != self.needle {
// String.Index values are only valid for the string that created
// them, so publish a nil selection before changing the string.
needleSelection = nil
self.needle = needle
}
if selectAll {
needleSelection = self.needle.startIndex..<self.needle.endIndex
}
}
/// Updates the selection only when both indices are valid for the needle.
func setNeedleSelection(_ selection: Range<String.Index>?) {
guard let selection else {
needleSelection = nil
return
}
guard
let lowerBound = String.Index(selection.lowerBound, within: needle),
let upperBound = String.Index(selection.upperBound, within: needle)
else {
needleSelection = nil
return
}
needleSelection = lowerBound..<upperBound
}
func readPasteboardNeedle() {
let pasteboardNeedle = pasteboard.string
if let pasteboardNeedle, pasteboardNeedle != needle {
needle = pasteboardNeedle
needleSelection = needle.startIndex..<needle.endIndex
setNeedle(pasteboardNeedle, selectAll: true)
}
}

View File

@@ -379,8 +379,14 @@ extension Ghostty {
HStack(spacing: 4) {
BackportSelectionTextField(
"Search",
text: $searchState.needle,
selection: $searchState.needleSelection
text: Binding(
get: { searchState.needle },
set: { searchState.setNeedle($0) }
),
selection: Binding(
get: { searchState.needleSelection },
set: { searchState.setNeedleSelection($0) }
)
)
.textFieldStyle(.plain)
.frame(width: 180)

View File

@@ -57,18 +57,44 @@ import Testing
from: StartSearch(c: .init(needle: nil)),
pasteboard: pasteboard
)
sut.needle = "sut"
sut.setNeedle("sut")
sut.writePasteboardNeedle()
#expect(pasteboard.string(forType: .string) == "sut")
}
@Test func setNeedle_clearsNeedleSelection() {
let sut = SearchState(
from: StartSearch(c: .init(needle: nil)),
pasteboard: pasteboard
)
sut.setNeedleSelection(sut.needle.startIndex..<sut.needle.endIndex)
sut.setNeedle("x")
#expect(sut.needle == "x")
#expect(sut.needleSelection == nil)
}
@Test func setNeedleSelection_ignoresRangeFromLongerNeedle() {
let sut = SearchState(
from: StartSearch(c: .init(needle: nil)),
pasteboard: pasteboard
)
let oldNeedle = "abcdefghijklmnopqrstuvwxyz"
sut.setNeedle("x")
sut.setNeedleSelection(oldNeedle.startIndex..<oldNeedle.endIndex)
#expect(sut.needleSelection == nil)
}
@Test func readPasteboardNeedle_whenPasteboardNeedleIsNil() {
let sut = SearchState(
from: StartSearch(c: .init(needle: nil)),
pasteboard: pasteboard
)
pasteboard.clearContents()
sut.needle = "sut"
sut.setNeedle("sut")
sut.readPasteboardNeedle()
#expect(sut.needle == "sut")
}
@@ -78,7 +104,7 @@ import Testing
from: StartSearch(c: .init(needle: nil)),
pasteboard: pasteboard
)
sut.needle = "sut"
sut.setNeedle("sut")
sut.readPasteboardNeedle()
#expect(sut.needle == "pb")
}
@@ -88,7 +114,7 @@ import Testing
from: StartSearch(c: .init(needle: nil)),
pasteboard: pasteboard
)
sut.needle = "sut"
sut.setNeedle("sut")
sut.readPasteboardNeedle()
let expected = "pb".startIndex..<"pb".endIndex