Allow search/replace to span multiple lines (#50783)

claire and Nathan Sobo created

Closes #49957 

Also adds `start_of_input` context, and modifies both
`{start,end}_of_input` to work for both single line and auto height
editor modes.


https://github.com/user-attachments/assets/e30f2b20-a96c-49d5-9eb6-3c95a485d14a

Before you mark this PR as ready for review, make sure that you have:
- [x] Added a solid test coverage and/or screenshots from doing manual
testing
- [x] Done a self-review taking into account security and performance
aspects
- [x] Aligned any UI changes with the [UI
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)

Release Notes:

- Added support for multi-line search and replace input in Buffer Search
and Project Search

---------

Co-authored-by: Nathan Sobo <nathan@zed.dev>

Change summary

assets/keymaps/default-linux.json                  |  14 +
assets/keymaps/default-macos.json                  |  14 +
assets/keymaps/default-windows.json                |  14 +
crates/debugger_ui/src/session/running/console.rs  |   3 
crates/editor/src/editor.rs                        |  21 +
crates/editor/src/editor_tests.rs                  |  33 ++
crates/editor/src/items.rs                         |   9 
crates/project/src/search_history.rs               |  29 ++
crates/project/tests/integration/search_history.rs |  21 +
crates/search/src/buffer_search.rs                 | 182 +++++++++++----
crates/search/src/project_search.rs                | 148 ++++++++++--
crates/search/src/search_bar.rs                    |  33 ++
12 files changed, 411 insertions(+), 110 deletions(-)

Detailed changes

assets/keymaps/default-linux.json πŸ”—

@@ -391,6 +391,14 @@
       "ctrl-enter": "search::ReplaceAll",
     },
   },
+  {
+    "context": "BufferSearchBar && !in_replace > Editor",
+    "use_key_equivalents": true,
+    "bindings": {
+      "ctrl-enter": "editor::Newline",
+      "shift-enter": "search::SelectPreviousMatch",
+    },
+  },
   {
     "context": "BufferSearchBar && !in_replace > Editor",
     "bindings": {
@@ -424,6 +432,12 @@
       "ctrl-alt-enter": "search::ReplaceAll",
     },
   },
+  {
+    "context": "ProjectSearchBar && !in_replace > Editor",
+    "bindings": {
+      "ctrl-enter": "editor::Newline",
+    },
+  },
   {
     "context": "ProjectSearchView",
     "bindings": {

assets/keymaps/default-macos.json πŸ”—

@@ -446,6 +446,13 @@
   {
     "context": "BufferSearchBar && !in_replace > Editor",
     "use_key_equivalents": true,
+    "bindings": {
+      "ctrl-enter": "editor::Newline",
+      "shift-enter": "search::SelectPreviousMatch",
+    },
+  },
+  {
+    "context": "BufferSearchBar && !in_replace > Editor",
     "bindings": {
       "up": "search::PreviousHistoryQuery",
       "down": "search::NextHistoryQuery",
@@ -473,7 +480,6 @@
   },
   {
     "context": "ProjectSearchBar > Editor",
-    "use_key_equivalents": true,
     "bindings": {
       "up": "search::PreviousHistoryQuery",
       "down": "search::NextHistoryQuery",
@@ -487,6 +493,12 @@
       "cmd-enter": "search::ReplaceAll",
     },
   },
+  {
+    "context": "ProjectSearchBar && !in_replace > Editor",
+    "bindings": {
+      "ctrl-enter": "editor::Newline",
+    },
+  },
   {
     "context": "ProjectSearchView",
     "use_key_equivalents": true,

assets/keymaps/default-windows.json πŸ”—

@@ -398,6 +398,13 @@
   {
     "context": "BufferSearchBar && !in_replace > Editor",
     "use_key_equivalents": true,
+    "bindings": {
+      "ctrl-enter": "editor::Newline",
+      "shift-enter": "search::SelectPreviousMatch",
+    },
+  },
+  {
+    "context": "BufferSearchBar && !in_replace > Editor",
     "bindings": {
       "up": "search::PreviousHistoryQuery",
       "down": "search::NextHistoryQuery",
@@ -415,7 +422,6 @@
   },
   {
     "context": "ProjectSearchBar > Editor",
-    "use_key_equivalents": true,
     "bindings": {
       "up": "search::PreviousHistoryQuery",
       "down": "search::NextHistoryQuery",
@@ -429,6 +435,12 @@
       "ctrl-alt-enter": "search::ReplaceAll",
     },
   },
+  {
+    "context": "ProjectSearchBar && !in_replace > Editor",
+    "bindings": {
+      "ctrl-enter": "editor::Newline",
+    },
+  },
   {
     "context": "ProjectSearchView",
     "use_key_equivalents": true,

crates/debugger_ui/src/session/running/console.rs πŸ”—

@@ -303,7 +303,8 @@ impl Console {
     }
 
     fn previous_query(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
-        let prev = self.history.previous(&mut self.cursor);
+        let current_query = self.query_bar.read(cx).text(cx);
+        let prev = self.history.previous(&mut self.cursor, &current_query);
         if let Some(prev) = prev {
             self.query_bar.update(cx, |editor, cx| {
                 editor.set_text(prev, window, cx);

crates/editor/src/editor.rs πŸ”—

@@ -2891,14 +2891,23 @@ impl Editor {
         }
 
         let disjoint = self.selections.disjoint_anchors();
-        let snapshot = self.snapshot(window, cx);
-        let snapshot = snapshot.buffer_snapshot();
-        if self.mode == EditorMode::SingleLine
-            && let [selection] = disjoint
+        if matches!(
+            &self.mode,
+            EditorMode::SingleLine | EditorMode::AutoHeight { .. }
+        ) && let [selection] = disjoint
             && selection.start == selection.end
-            && selection.end.to_offset(snapshot) == snapshot.len()
         {
-            key_context.add("end_of_input");
+            let snapshot = self.snapshot(window, cx);
+            let snapshot = snapshot.buffer_snapshot();
+            let caret_offset = selection.end.to_offset(snapshot);
+
+            if caret_offset == MultiBufferOffset(0) {
+                key_context.add("start_of_input");
+            }
+
+            if caret_offset == snapshot.len() {
+                key_context.add("end_of_input");
+            }
         }
 
         if self.has_any_expanded_diff_hunks(cx) {

crates/editor/src/editor_tests.rs πŸ”—

@@ -30815,14 +30815,47 @@ async fn test_end_of_editor_context(cx: &mut TestAppContext) {
     cx.set_state("line1\nline2Λ‡");
     cx.update_editor(|e, window, cx| {
         e.set_mode(EditorMode::SingleLine);
+        assert!(!e.key_context(window, cx).contains("start_of_input"));
         assert!(e.key_context(window, cx).contains("end_of_input"));
     });
     cx.set_state("Λ‡line1\nline2");
     cx.update_editor(|e, window, cx| {
+        e.set_mode(EditorMode::SingleLine);
+        assert!(e.key_context(window, cx).contains("start_of_input"));
+        assert!(!e.key_context(window, cx).contains("end_of_input"));
+    });
+    cx.set_state("line1Λ‡\nline2");
+    cx.update_editor(|e, window, cx| {
+        e.set_mode(EditorMode::SingleLine);
+        assert!(!e.key_context(window, cx).contains("start_of_input"));
+        assert!(!e.key_context(window, cx).contains("end_of_input"));
+    });
+
+    cx.set_state("line1\nline2Λ‡");
+    cx.update_editor(|e, window, cx| {
+        e.set_mode(EditorMode::AutoHeight {
+            min_lines: 1,
+            max_lines: Some(4),
+        });
+        assert!(!e.key_context(window, cx).contains("start_of_input"));
+        assert!(e.key_context(window, cx).contains("end_of_input"));
+    });
+    cx.set_state("Λ‡line1\nline2");
+    cx.update_editor(|e, window, cx| {
+        e.set_mode(EditorMode::AutoHeight {
+            min_lines: 1,
+            max_lines: Some(4),
+        });
+        assert!(e.key_context(window, cx).contains("start_of_input"));
         assert!(!e.key_context(window, cx).contains("end_of_input"));
     });
     cx.set_state("line1Λ‡\nline2");
     cx.update_editor(|e, window, cx| {
+        e.set_mode(EditorMode::AutoHeight {
+            min_lines: 1,
+            max_lines: Some(4),
+        });
+        assert!(!e.key_context(window, cx).contains("start_of_input"));
         assert!(!e.key_context(window, cx).contains("end_of_input"));
     });
 }

crates/editor/src/items.rs πŸ”—

@@ -1645,14 +1645,9 @@ impl SearchableItem for Editor {
         match setting {
             SeedQuerySetting::Never => String::new(),
             SeedQuerySetting::Selection | SeedQuerySetting::Always if !selection.is_empty() => {
-                let text: String = buffer_snapshot
+                buffer_snapshot
                     .text_for_range(selection.start..selection.end)
-                    .collect();
-                if text.contains('\n') {
-                    String::new()
-                } else {
-                    text
-                }
+                    .collect()
             }
             SeedQuerySetting::Selection => String::new(),
             SeedQuerySetting::Always => {

crates/project/src/search_history.rs πŸ”—

@@ -19,12 +19,19 @@ pub enum QueryInsertionBehavior {
 #[derive(Default, Debug, Clone, PartialEq, Eq, Hash)]
 pub struct SearchHistoryCursor {
     selection: Option<usize>,
+    draft: Option<String>,
 }
 
 impl SearchHistoryCursor {
-    /// Resets the selection to `None`.
+    /// Resets the selection to `None` and clears the draft.
     pub fn reset(&mut self) {
         self.selection = None;
+        self.draft = None;
+    }
+
+    /// Takes the stored draft query, if any.
+    pub fn take_draft(&mut self) -> Option<String> {
+        self.draft.take()
     }
 }
 
@@ -45,6 +52,8 @@ impl SearchHistory {
     }
 
     pub fn add(&mut self, cursor: &mut SearchHistoryCursor, search_string: String) {
+        cursor.draft = None;
+
         if self.insertion_behavior == QueryInsertionBehavior::ReplacePreviousIfContains
             && let Some(previously_searched) = self.history.back_mut()
             && search_string.contains(previously_searched.as_str())
@@ -81,7 +90,23 @@ impl SearchHistory {
 
     /// Get the previous history entry using the given `SearchHistoryCursor`.
     /// Uses the last element in the history when there is no cursor.
-    pub fn previous(&mut self, cursor: &mut SearchHistoryCursor) -> Option<&str> {
+    ///
+    /// `current_query` is the current text in the search editor. If it differs
+    /// from the history entry at the cursor position (or if the cursor has no
+    /// selection), it is saved as a draft so it can be restored later.
+    pub fn previous(
+        &mut self,
+        cursor: &mut SearchHistoryCursor,
+        current_query: &str,
+    ) -> Option<&str> {
+        let matches_history = cursor
+            .selection
+            .and_then(|i| self.history.get(i))
+            .is_some_and(|entry| entry == current_query);
+        if !matches_history {
+            cursor.draft = Some(current_query.to_string());
+        }
+
         let prev_index = match cursor.selection {
             Some(index) => index.checked_sub(1)?,
             None => self.history.len().checked_sub(1)?,

crates/project/tests/integration/search_history.rs πŸ”—

@@ -38,7 +38,7 @@ fn test_add() {
 
     // add item when it equals to current item if it's not the last one
     search_history.add(&mut cursor, "php".to_string());
-    search_history.previous(&mut cursor);
+    search_history.previous(&mut cursor, "");
     assert_eq!(search_history.current(&cursor), Some("rustlang"));
     search_history.add(&mut cursor, "rustlang".to_string());
     assert_eq!(search_history.len(), 3, "Should add item");
@@ -71,13 +71,13 @@ fn test_next_and_previous() {
 
     assert_eq!(search_history.current(&cursor), Some("TypeScript"));
 
-    assert_eq!(search_history.previous(&mut cursor), Some("JavaScript"));
+    assert_eq!(search_history.previous(&mut cursor, ""), Some("JavaScript"));
     assert_eq!(search_history.current(&cursor), Some("JavaScript"));
 
-    assert_eq!(search_history.previous(&mut cursor), Some("Rust"));
+    assert_eq!(search_history.previous(&mut cursor, ""), Some("Rust"));
     assert_eq!(search_history.current(&cursor), Some("Rust"));
 
-    assert_eq!(search_history.previous(&mut cursor), None);
+    assert_eq!(search_history.previous(&mut cursor, ""), None);
     assert_eq!(search_history.current(&cursor), Some("Rust"));
 
     assert_eq!(search_history.next(&mut cursor), Some("JavaScript"));
@@ -103,14 +103,14 @@ fn test_reset_selection() {
     cursor.reset();
     assert_eq!(search_history.current(&cursor), None);
     assert_eq!(
-        search_history.previous(&mut cursor),
+        search_history.previous(&mut cursor, ""),
         Some("TypeScript"),
         "Should start from the end after reset on previous item query"
     );
 
-    search_history.previous(&mut cursor);
+    search_history.previous(&mut cursor, "");
     assert_eq!(search_history.current(&cursor), Some("JavaScript"));
-    search_history.previous(&mut cursor);
+    search_history.previous(&mut cursor, "");
     assert_eq!(search_history.current(&cursor), Some("Rust"));
 
     cursor.reset();
@@ -134,8 +134,11 @@ fn test_multiple_cursors() {
     assert_eq!(search_history.current(&cursor1), Some("TypeScript"));
     assert_eq!(search_history.current(&cursor2), Some("C++"));
 
-    assert_eq!(search_history.previous(&mut cursor1), Some("JavaScript"));
-    assert_eq!(search_history.previous(&mut cursor2), Some("Java"));
+    assert_eq!(
+        search_history.previous(&mut cursor1, ""),
+        Some("JavaScript")
+    );
+    assert_eq!(search_history.previous(&mut cursor2, ""), Some("Java"));
 
     assert_eq!(search_history.next(&mut cursor1), Some("TypeScript"));
     assert_eq!(search_history.next(&mut cursor1), Some("Python"));

crates/search/src/buffer_search.rs πŸ”—

@@ -6,8 +6,9 @@ use crate::{
     ToggleCaseSensitive, ToggleRegex, ToggleReplace, ToggleSelection, ToggleWholeWord,
     buffer_search::registrar::WithResultsOrExternalQuery,
     search_bar::{
-        ActionButtonState, alignment_element, filter_search_results_input, input_base_styles,
-        render_action_button, render_text_input,
+        ActionButtonState, HistoryNavigationDirection, alignment_element,
+        filter_search_results_input, input_base_styles, render_action_button, render_text_input,
+        should_navigate_history,
     },
 };
 use any_vec::AnyVec;
@@ -15,6 +16,7 @@ use collections::HashMap;
 use editor::{
     Editor, EditorSettings, MultiBufferOffset, SplittableEditor, ToggleSplitDiff,
     actions::{Backtab, FoldAll, Tab, ToggleFoldAll, UnfoldAll},
+    scroll::Autoscroll,
 };
 use futures::channel::oneshot;
 use gpui::{
@@ -337,13 +339,11 @@ impl Render for BufferSearchBar {
         };
 
         let query_column = input_style
-            .child(
-                div()
-                    .flex_1()
-                    .min_w(px(0.))
-                    .overflow_hidden()
-                    .child(render_text_input(&self.query_editor, color_override, cx)),
-            )
+            .child(div().flex_1().min_w_0().py_1().child(render_text_input(
+                &self.query_editor,
+                color_override,
+                cx,
+            )))
             .child(
                 h_flex()
                     .flex_none()
@@ -484,39 +484,42 @@ impl Render for BufferSearchBar {
             .child(query_column)
             .child(mode_column);
 
-        let replace_line =
-            should_show_replace_input.then(|| {
-                let replace_column = input_base_styles(replacement_border)
-                    .child(render_text_input(&self.replacement_editor, None, cx));
-                let focus_handle = self.replacement_editor.read(cx).focus_handle(cx);
-
-                let replace_actions = h_flex()
-                    .min_w_64()
-                    .gap_1()
-                    .child(render_action_button(
-                        "buffer-search-replace-button",
-                        IconName::ReplaceNext,
-                        Default::default(),
-                        "Replace Next Match",
-                        &ReplaceNext,
-                        focus_handle.clone(),
-                    ))
-                    .child(render_action_button(
-                        "buffer-search-replace-button",
-                        IconName::ReplaceAll,
-                        Default::default(),
-                        "Replace All Matches",
-                        &ReplaceAll,
-                        focus_handle,
-                    ));
+        let replace_line = should_show_replace_input.then(|| {
+            let replace_column = input_base_styles(replacement_border).child(
+                div()
+                    .flex_1()
+                    .py_1()
+                    .child(render_text_input(&self.replacement_editor, None, cx)),
+            );
+            let focus_handle = self.replacement_editor.read(cx).focus_handle(cx);
+
+            let replace_actions = h_flex()
+                .min_w_64()
+                .gap_1()
+                .child(render_action_button(
+                    "buffer-search-replace-button",
+                    IconName::ReplaceNext,
+                    Default::default(),
+                    "Replace Next Match",
+                    &ReplaceNext,
+                    focus_handle.clone(),
+                ))
+                .child(render_action_button(
+                    "buffer-search-replace-button",
+                    IconName::ReplaceAll,
+                    Default::default(),
+                    "Replace All Matches",
+                    &ReplaceAll,
+                    focus_handle,
+                ));
 
-                h_flex()
-                    .w_full()
-                    .gap_2()
-                    .when(has_collapse_button, |this| this.child(alignment_element()))
-                    .child(replace_column)
-                    .child(replace_actions)
-            });
+            h_flex()
+                .w_full()
+                .gap_2()
+                .when(has_collapse_button, |this| this.child(alignment_element()))
+                .child(replace_column)
+                .child(replace_actions)
+        });
 
         let mut key_context = KeyContext::new_with_defaults();
         key_context.add("BufferSearchBar");
@@ -831,13 +834,13 @@ impl BufferSearchBar {
         cx: &mut Context<Self>,
     ) -> Self {
         let query_editor = cx.new(|cx| {
-            let mut editor = Editor::single_line(window, cx);
+            let mut editor = Editor::auto_height(1, 4, window, cx);
             editor.set_use_autoclose(false);
             editor
         });
         cx.subscribe_in(&query_editor, window, Self::on_query_editor_event)
             .detach();
-        let replacement_editor = cx.new(|cx| Editor::single_line(window, cx));
+        let replacement_editor = cx.new(|cx| Editor::auto_height(1, 4, window, cx));
         cx.subscribe(&replacement_editor, Self::on_replacement_editor_event)
             .detach();
 
@@ -1186,6 +1189,7 @@ impl BufferSearchBar {
                     let len = query_buffer.len(cx);
                     query_buffer.edit([(MultiBufferOffset(0)..len, query)], None, cx);
                 });
+                query_editor.request_autoscroll(Autoscroll::fit(), cx);
             });
             self.set_search_options(options, cx);
             self.clear_matches(window, cx);
@@ -1704,15 +1708,19 @@ impl BufferSearchBar {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
+        if !should_navigate_history(&self.query_editor, HistoryNavigationDirection::Next, cx) {
+            cx.propagate();
+            return;
+        }
+
         if let Some(new_query) = self
             .search_history
             .next(&mut self.search_history_cursor)
             .map(str::to_string)
         {
             drop(self.search(&new_query, Some(self.search_options), false, window, cx));
-        } else {
-            self.search_history_cursor.reset();
-            drop(self.search("", Some(self.search_options), false, window, cx));
+        } else if let Some(draft) = self.search_history_cursor.take_draft() {
+            drop(self.search(&draft, Some(self.search_options), false, window, cx));
         }
     }
 
@@ -1722,6 +1730,11 @@ impl BufferSearchBar {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
+        if !should_navigate_history(&self.query_editor, HistoryNavigationDirection::Previous, cx) {
+            cx.propagate();
+            return;
+        }
+
         if self.query(cx).is_empty()
             && let Some(new_query) = self
                 .search_history
@@ -1732,9 +1745,10 @@ impl BufferSearchBar {
             return;
         }
 
+        let current_query = self.query(cx);
         if let Some(new_query) = self
             .search_history
-            .previous(&mut self.search_history_cursor)
+            .previous(&mut self.search_history_cursor, &current_query)
             .map(str::to_string)
         {
             drop(self.search(&new_query, Some(self.search_options), false, window, cx));
@@ -2716,13 +2730,13 @@ mod tests {
             assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE);
         });
 
-        // Next history query after the latest should set the query to the empty string.
+        // Next history query after the latest should preserve the current query.
         search_bar.update_in(cx, |search_bar, window, cx| {
             search_bar.next_history_query(&NextHistoryQuery, window, cx);
         });
         cx.background_executor.run_until_parked();
         search_bar.update(cx, |search_bar, cx| {
-            assert_eq!(search_bar.query(cx), "");
+            assert_eq!(search_bar.query(cx), "c");
             assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE);
         });
         search_bar.update_in(cx, |search_bar, window, cx| {
@@ -2730,17 +2744,17 @@ mod tests {
         });
         cx.background_executor.run_until_parked();
         search_bar.update(cx, |search_bar, cx| {
-            assert_eq!(search_bar.query(cx), "");
+            assert_eq!(search_bar.query(cx), "c");
             assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE);
         });
 
-        // First previous query for empty current query should set the query to the latest.
+        // Previous query should navigate backwards through history.
         search_bar.update_in(cx, |search_bar, window, cx| {
             search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
         });
         cx.background_executor.run_until_parked();
         search_bar.update(cx, |search_bar, cx| {
-            assert_eq!(search_bar.query(cx), "c");
+            assert_eq!(search_bar.query(cx), "b");
             assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE);
         });
 
@@ -2750,7 +2764,7 @@ mod tests {
         });
         cx.background_executor.run_until_parked();
         search_bar.update(cx, |search_bar, cx| {
-            assert_eq!(search_bar.query(cx), "b");
+            assert_eq!(search_bar.query(cx), "a");
             assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE);
         });
 
@@ -2831,11 +2845,71 @@ mod tests {
         });
         cx.background_executor.run_until_parked();
         search_bar.update(cx, |search_bar, cx| {
-            assert_eq!(search_bar.query(cx), "");
+            assert_eq!(search_bar.query(cx), "ba");
             assert_eq!(search_bar.search_options, SearchOptions::NONE);
         });
     }
 
+    #[perf]
+    #[gpui::test]
+    async fn test_search_query_history_autoscroll(cx: &mut TestAppContext) {
+        let (_editor, search_bar, cx) = init_test(cx);
+
+        // Add a long multi-line query that exceeds the editor's max
+        // visible height (4 lines), then a short query.
+        let long_query = "line1\nline2\nline3\nline4\nline5\nline6";
+        search_bar
+            .update_in(cx, |search_bar, window, cx| {
+                search_bar.search(long_query, None, true, window, cx)
+            })
+            .await
+            .unwrap();
+        search_bar
+            .update_in(cx, |search_bar, window, cx| {
+                search_bar.search("short", None, true, window, cx)
+            })
+            .await
+            .unwrap();
+
+        // Navigate back to the long entry. Since "short" is single-line,
+        // the history navigation is allowed.
+        search_bar.update_in(cx, |search_bar, window, cx| {
+            search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
+        });
+        cx.background_executor.run_until_parked();
+        search_bar.update(cx, |search_bar, cx| {
+            assert_eq!(search_bar.query(cx), long_query);
+        });
+
+        // The cursor should be scrolled into view despite the content
+        // exceeding the editor's max visible height.
+        search_bar.update_in(cx, |search_bar, window, cx| {
+            let snapshot = search_bar
+                .query_editor
+                .update(cx, |editor, cx| editor.snapshot(window, cx));
+            let cursor_row = search_bar
+                .query_editor
+                .read(cx)
+                .selections
+                .newest_display(&snapshot)
+                .head()
+                .row();
+            let scroll_top = search_bar
+                .query_editor
+                .update(cx, |editor, cx| editor.scroll_position(cx).y);
+            let visible_lines = search_bar
+                .query_editor
+                .read(cx)
+                .visible_line_count()
+                .unwrap_or(0.0);
+            let scroll_bottom = scroll_top + visible_lines;
+            assert!(
+                (cursor_row.0 as f64) < scroll_bottom,
+                "cursor row {cursor_row:?} should be visible (scroll range {scroll_top}..{scroll_bottom})"
+            );
+        });
+    }
+
     #[perf]
     #[gpui::test]
     async fn test_replace_simple(cx: &mut TestAppContext) {

crates/search/src/project_search.rs πŸ”—

@@ -4,8 +4,8 @@ use crate::{
     ToggleCaseSensitive, ToggleIncludeIgnored, ToggleRegex, ToggleReplace, ToggleWholeWord,
     buffer_search::Deploy,
     search_bar::{
-        ActionButtonState, alignment_element, input_base_styles, render_action_button,
-        render_text_input,
+        ActionButtonState, HistoryNavigationDirection, alignment_element, input_base_styles,
+        render_action_button, render_text_input, should_navigate_history,
     },
 };
 use anyhow::Context as _;
@@ -934,7 +934,7 @@ impl ProjectSearchView {
         }));
 
         let query_editor = cx.new(|cx| {
-            let mut editor = Editor::single_line(window, cx);
+            let mut editor = Editor::auto_height(1, 4, window, cx);
             editor.set_placeholder_text("Search all files…", window, cx);
             editor.set_text(query_text, window, cx);
             editor
@@ -957,7 +957,7 @@ impl ProjectSearchView {
             }),
         );
         let replacement_editor = cx.new(|cx| {
-            let mut editor = Editor::single_line(window, cx);
+            let mut editor = Editor::auto_height(1, 4, window, cx);
             editor.set_placeholder_text("Replace in project…", window, cx);
             if let Some(text) = replacement_text {
                 editor.set_text(text, window, cx);
@@ -1551,8 +1551,9 @@ impl ProjectSearchView {
 
             SearchInputKind::Exclude => &self.excluded_files_editor,
         };
-        editor.update(cx, |included_editor, cx| {
-            included_editor.set_text(text, window, cx)
+        editor.update(cx, |editor, cx| {
+            editor.set_text(text, window, cx);
+            editor.request_autoscroll(Autoscroll::fit(), cx);
         });
     }
 
@@ -1997,6 +1998,11 @@ impl ProjectSearchBar {
                     ),
                 ] {
                     if editor.focus_handle(cx).is_focused(window) {
+                        if !should_navigate_history(&editor, HistoryNavigationDirection::Next, cx) {
+                            cx.propagate();
+                            return;
+                        }
+
                         let new_query = search_view.entity.update(cx, |model, cx| {
                             let project = model.project.clone();
 
@@ -2006,13 +2012,14 @@ impl ProjectSearchBar {
                                     .next(model.cursor_mut(kind))
                                     .map(str::to_string)
                             }) {
-                                new_query
+                                Some(new_query)
                             } else {
-                                model.cursor_mut(kind).reset();
-                                String::new()
+                                model.cursor_mut(kind).take_draft()
                             }
                         });
-                        search_view.set_search_editor(kind, &new_query, window, cx);
+                        if let Some(new_query) = new_query {
+                            search_view.set_search_editor(kind, &new_query, window, cx);
+                        }
                     }
                 }
             });
@@ -2039,6 +2046,15 @@ impl ProjectSearchBar {
                     ),
                 ] {
                     if editor.focus_handle(cx).is_focused(window) {
+                        if !should_navigate_history(
+                            &editor,
+                            HistoryNavigationDirection::Previous,
+                            cx,
+                        ) {
+                            cx.propagate();
+                            return;
+                        }
+
                         if editor.read(cx).text(cx).is_empty()
                             && let Some(new_query) = search_view
                                 .entity
@@ -2053,12 +2069,13 @@ impl ProjectSearchBar {
                             return;
                         }
 
+                        let current_query = editor.read(cx).text(cx);
                         if let Some(new_query) = search_view.entity.update(cx, |model, cx| {
                             let project = model.project.clone();
                             project.update(cx, |project, _| {
                                 project
                                     .search_history_mut(kind)
-                                    .previous(model.cursor_mut(kind))
+                                    .previous(model.cursor_mut(kind), &current_query)
                                     .map(str::to_string)
                             })
                         }) {
@@ -2157,7 +2174,11 @@ impl Render for ProjectSearchBar {
             .on_action(
                 cx.listener(|this, action, window, cx| this.next_history_query(action, window, cx)),
             )
-            .child(render_text_input(&search.query_editor, color_override, cx))
+            .child(div().flex_1().py_1().child(render_text_input(
+                &search.query_editor,
+                color_override,
+                cx,
+            )))
             .child(
                 h_flex()
                     .gap_1()
@@ -2315,8 +2336,13 @@ impl Render for ProjectSearchBar {
             .child(mode_column);
 
         let replace_line = search.replace_enabled.then(|| {
-            let replace_column = input_base_styles(InputPanel::Replacement)
-                .child(render_text_input(&search.replacement_editor, None, cx));
+            let replace_column = input_base_styles(InputPanel::Replacement).child(
+                div().flex_1().py_1().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()
@@ -3915,7 +3941,7 @@ pub mod tests {
             })
             .unwrap();
 
-        // Next history query after the latest should set the query to the empty string.
+        // Next history query after the latest should preserve the current query.
         window
             .update(cx, |_, window, cx| {
                 search_bar.update(cx, |search_bar, cx| {
@@ -3927,7 +3953,10 @@ pub mod tests {
         window
             .update(cx, |_, _, cx| {
                 search_view.update(cx, |search_view, cx| {
-                    assert_eq!(search_view.query_editor.read(cx).text(cx), "");
+                    assert_eq!(
+                        search_view.query_editor.read(cx).text(cx),
+                        "JUST_TEXT_INPUT"
+                    );
                     assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
                 });
             })
@@ -3943,13 +3972,16 @@ pub mod tests {
         window
             .update(cx, |_, _, cx| {
                 search_view.update(cx, |search_view, cx| {
-                    assert_eq!(search_view.query_editor.read(cx).text(cx), "");
+                    assert_eq!(
+                        search_view.query_editor.read(cx).text(cx),
+                        "JUST_TEXT_INPUT"
+                    );
                     assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
                 });
             })
             .unwrap();
 
-        // First previous query for empty current query should set the query to the latest submitted one.
+        // Previous query should navigate backwards through history.
         window
             .update(cx, |_, window, cx| {
                 search_bar.update(cx, |search_bar, cx| {
@@ -3961,7 +3993,7 @@ pub mod tests {
         window
             .update(cx, |_, _, cx| {
                 search_view.update(cx, |search_view, cx| {
-                    assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
+                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
                     assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
                 });
             })
@@ -3979,7 +4011,7 @@ pub mod tests {
         window
             .update(cx, |_, _, cx| {
                 search_view.update(cx, |search_view, cx| {
-                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
+                    assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
                     assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
                 });
             })
@@ -4133,11 +4165,75 @@ pub mod tests {
         window
             .update(cx, |_, _, cx| {
                 search_view.update(cx, |search_view, cx| {
-                    assert_eq!(search_view.query_editor.read(cx).text(cx), "");
+                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
                     assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
                 });
             })
             .unwrap();
+
+        // Typing text without running a search, then navigating history, should allow
+        // restoring the draft when pressing next past the end.
+        window
+            .update(cx, |_, window, cx| {
+                search_view.update(cx, |search_view, cx| {
+                    search_view.query_editor.update(cx, |query_editor, cx| {
+                        query_editor.set_text("unsaved draft", window, cx)
+                    });
+                })
+            })
+            .unwrap();
+        cx.background_executor.run_until_parked();
+
+        // Navigate up into history β€” the draft should be stashed.
+        window
+            .update(cx, |_, window, cx| {
+                search_bar.update(cx, |search_bar, cx| {
+                    search_bar.focus_search(window, cx);
+                    search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
+                });
+            })
+            .unwrap();
+        window
+            .update(cx, |_, _, cx| {
+                search_view.update(cx, |search_view, cx| {
+                    assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
+                });
+            })
+            .unwrap();
+
+        // Navigate forward through history.
+        window
+            .update(cx, |_, window, cx| {
+                search_bar.update(cx, |search_bar, cx| {
+                    search_bar.focus_search(window, cx);
+                    search_bar.next_history_query(&NextHistoryQuery, window, cx);
+                });
+            })
+            .unwrap();
+        window
+            .update(cx, |_, _, cx| {
+                search_view.update(cx, |search_view, cx| {
+                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
+                });
+            })
+            .unwrap();
+
+        // Navigate past the end β€” the draft should be restored.
+        window
+            .update(cx, |_, window, cx| {
+                search_bar.update(cx, |search_bar, cx| {
+                    search_bar.focus_search(window, cx);
+                    search_bar.next_history_query(&NextHistoryQuery, window, cx);
+                });
+            })
+            .unwrap();
+        window
+            .update(cx, |_, _, cx| {
+                search_view.update(cx, |search_view, cx| {
+                    assert_eq!(search_view.query_editor.read(cx).text(cx), "unsaved draft");
+                });
+            })
+            .unwrap();
     }
 
     #[perf]
@@ -4323,9 +4419,6 @@ pub mod tests {
         cx.background_executor.run_until_parked();
 
         select_next_history_item(&search_bar_2, cx);
-        assert_eq!(active_query(&search_view_2, cx), "");
-
-        select_prev_history_item(&search_bar_2, cx);
         assert_eq!(active_query(&search_view_2, cx), "THREE");
 
         select_prev_history_item(&search_bar_2, cx);
@@ -4337,6 +4430,9 @@ pub mod tests {
         select_prev_history_item(&search_bar_2, cx);
         assert_eq!(active_query(&search_view_2, cx), "ONE");
 
+        select_prev_history_item(&search_bar_2, cx);
+        assert_eq!(active_query(&search_view_2, cx), "ONE");
+
         // Search view 1 should now see the query from search view 2.
         assert_eq!(active_query(&search_view_1, cx), "ONE");
 
@@ -4348,7 +4444,7 @@ pub mod tests {
         assert_eq!(active_query(&search_view_2, cx), "THREE");
 
         select_next_history_item(&search_bar_2, cx);
-        assert_eq!(active_query(&search_view_2, cx), "");
+        assert_eq!(active_query(&search_view_2, cx), "THREE");
 
         select_next_history_item(&search_bar_1, cx);
         assert_eq!(active_query(&search_view_1, cx), "TWO");
@@ -4357,7 +4453,7 @@ pub mod tests {
         assert_eq!(active_query(&search_view_1, cx), "THREE");
 
         select_next_history_item(&search_bar_1, cx);
-        assert_eq!(active_query(&search_view_1, cx), "");
+        assert_eq!(active_query(&search_view_1, cx), "THREE");
     }
 
     #[perf]

crates/search/src/search_bar.rs πŸ”—

@@ -1,10 +1,37 @@
-use editor::{Editor, EditorElement, EditorStyle};
-use gpui::{Action, Entity, FocusHandle, Hsla, IntoElement, TextStyle};
+use editor::{Editor, EditorElement, EditorStyle, MultiBufferOffset, ToOffset};
+use gpui::{Action, App, Entity, FocusHandle, Hsla, IntoElement, TextStyle};
 use settings::Settings;
 use theme::ThemeSettings;
 use ui::{IconButton, IconButtonShape};
 use ui::{Tooltip, prelude::*};
 
+pub(super) enum HistoryNavigationDirection {
+    Previous,
+    Next,
+}
+
+pub(super) fn should_navigate_history(
+    editor: &Entity<Editor>,
+    direction: HistoryNavigationDirection,
+    cx: &App,
+) -> bool {
+    let editor_ref = editor.read(cx);
+    let snapshot = editor_ref.buffer().read(cx).snapshot(cx);
+    if snapshot.max_point().row == 0 {
+        return true;
+    }
+    let selections = editor_ref.selections.disjoint_anchors();
+    if let [selection] = selections {
+        let offset = selection.end.to_offset(&snapshot);
+        match direction {
+            HistoryNavigationDirection::Previous => offset == MultiBufferOffset(0),
+            HistoryNavigationDirection::Next => offset == snapshot.len(),
+        }
+    } else {
+        true
+    }
+}
+
 pub(super) enum ActionButtonState {
     Disabled,
     Toggled,
@@ -43,7 +70,7 @@ pub(crate) fn input_base_styles(border_color: Hsla, map: impl FnOnce(Div) -> Div
     h_flex()
         .map(map)
         .min_w_32()
-        .h_8()
+        .min_h_8()
         .pl_2()
         .pr_1()
         .border_1()