search: Fix replace all being silently dropped (#50852)

Om Chillure created

Fixes #50848

### Problem
When Replace All was triggered with a stale search query (i.e., the
query text had changed since the last completed search), the old code
ould restart the search and immediately return, silently discarding the
replace-all intent. This caused the Replace All action to appear to do
nothing on the first try, only working on subsequent attempts once
results were already loaded.

### Fix :
Fix this by introducing a `pending_replace_all` flag on
ProjectSearchView. When Replace All is invoked while a search is in
flight or the query is stale, the flag is set and the action is
deferred.
Once the search completes and `entity_changed` is called, the flag is
checked and `replace_all` is automatically dispatched.

Also disable the Replace Next button in the UI while a search is
underway, since it cannot meaningfully act without up-to-date results.

### Release Notes:

- Fixed "Replace All" in project search not working on the first attempt
when the search query was changed or results hadn't loaded yet.

Change summary

crates/search/src/project_search.rs | 30 ++++++++++++++++++++++++------
1 file changed, 24 insertions(+), 6 deletions(-)

Detailed changes

crates/search/src/project_search.rs 🔗

@@ -266,6 +266,7 @@ pub struct ProjectSearchView {
     excluded_files_editor: Entity<Editor>,
     filters_enabled: bool,
     replace_enabled: bool,
+    pending_replace_all: bool,
     included_opened_only: bool,
     regex_language: Option<Arc<Language>>,
     _subscriptions: Vec<Subscription>,
@@ -797,6 +798,9 @@ impl ProjectSearchView {
     }
 
     fn replace_next(&mut self, _: &ReplaceNext, window: &mut Window, cx: &mut Context<Self>) {
+        if self.entity.read(cx).pending_search.is_some() {
+            return;
+        }
         if let Some(last_search_query_text) = &self.entity.read(cx).last_search_query_text
             && self.query_editor.read(cx).text(cx) != *last_search_query_text
         {
@@ -824,14 +828,24 @@ impl ProjectSearchView {
             self.select_match(Direction::Next, window, cx)
         }
     }
+
     fn replace_all(&mut self, _: &ReplaceAll, window: &mut Window, cx: &mut Context<Self>) {
-        if let Some(last_search_query_text) = &self.entity.read(cx).last_search_query_text
-            && self.query_editor.read(cx).text(cx) != *last_search_query_text
-        {
-            // search query has changed, restart search and bail
+        if self.entity.read(cx).pending_search.is_some() {
+            self.pending_replace_all = true;
+            return;
+        }
+        let query_text = self.query_editor.read(cx).text(cx);
+        let query_is_stale =
+            self.entity.read(cx).last_search_query_text.as_deref() != Some(query_text.as_str());
+        if query_is_stale {
+            self.pending_replace_all = true;
             self.search(cx);
+            if self.entity.read(cx).pending_search.is_none() {
+                self.pending_replace_all = false;
+            }
             return;
         }
+        self.pending_replace_all = false;
         if self.active_match_index.is_none() {
             return;
         }
@@ -1043,6 +1057,7 @@ impl ProjectSearchView {
             excluded_files_editor,
             filters_enabled,
             replace_enabled: false,
+            pending_replace_all: false,
             included_opened_only: false,
             regex_language: None,
             _subscriptions: subscriptions,
@@ -1583,6 +1598,10 @@ impl ProjectSearchView {
 
         cx.emit(ViewEvent::UpdateTab);
         cx.notify();
+
+        if self.pending_replace_all && self.entity.read(cx).pending_search.is_none() {
+            self.replace_all(&ReplaceAll, window, cx);
+        }
     }
 
     fn update_match_index(&mut self, cx: &mut Context<Self>) {
@@ -2300,14 +2319,13 @@ impl Render for ProjectSearchBar {
                 .child(render_text_input(&search.replacement_editor, None, cx));
 
             let focus_handle = search.replacement_editor.read(cx).focus_handle(cx);
-
             let replace_actions = h_flex()
                 .min_w_64()
                 .gap_1()
                 .child(render_action_button(
                     "project-search-replace-button",
                     IconName::ReplaceNext,
-                    Default::default(),
+                    is_search_underway.then_some(ActionButtonState::Disabled),
                     "Replace Next Match",
                     &ReplaceNext,
                     focus_handle.clone(),