From 74f01cf5df5c3426c37951015500c92a20afdfa9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 5 Aug 2026 14:01:45 -0700 Subject: [PATCH] macos: prevent stale search selection crash 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. --- macos/Sources/Ghostty/Ghostty.App.swift | 2 +- .../Ghostty/Surface View/OSSurfaceView.swift | 41 ++++++++++++++++--- .../Ghostty/Surface View/SurfaceView.swift | 10 ++++- .../SurfaceView+SearchStateTests.swift | 34 +++++++++++++-- 4 files changed, 75 insertions(+), 12 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 7a0c738e9..91fab32fe 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -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) diff --git a/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift b/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift index bc822cbd9..2f559f2ae 100644 --- a/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift @@ -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? + @Published private(set) var needleSelection: Range? 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..?) { + 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..