mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-24 16:11:43 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user