From b0e35b65995e14059f7004ad9a037d5c11df1bb7 Mon Sep 17 00:00:00 2001 From: claire <28279548+claiwe@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:10:14 -0500 Subject: [PATCH] Allow search/replace to span multiple lines (#50783) 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 --- assets/keymaps/default-linux.json | 14 ++ assets/keymaps/default-macos.json | 14 +- assets/keymaps/default-windows.json | 14 +- .../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 ++- .../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(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 8671bb7be912b9ed89851f00aaa68ecb8af8ca56..56b983f7e763ed5a3a7d275bae1d9f53f1715db1 100644 --- a/assets/keymaps/default-linux.json +++ b/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": { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 26848eeed695e00b91e1f52015e7a11a1b8a03ed..9bd8856672d1f2aff1ae55301a8d5f095b9e1ca2 100644 --- a/assets/keymaps/default-macos.json +++ b/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, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index dcfee8ec86bcbe7cf54f4ccbf627de1d1f66cbe9..b698736fb459ce874da9a3d965b33993f68431ae 100644 --- a/assets/keymaps/default-windows.json +++ b/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, diff --git a/crates/debugger_ui/src/session/running/console.rs b/crates/debugger_ui/src/session/running/console.rs index 147dd9baf40e84c4801810ef109dcd8d15a26da8..e33efd2c4904fe83cbbffb9ae57aadfbfc6d5470 100644 --- a/crates/debugger_ui/src/session/running/console.rs +++ b/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) { - 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, ¤t_query); if let Some(prev) = prev { self.query_bar.update(cx, |editor, cx| { editor.set_text(prev, window, cx); diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index bfd82dac0ceeb38ee4d4eb74fa2270c3d885839f..761f732a11f31ddd59922c364478f9912843891b 100644 --- a/crates/editor/src/editor.rs +++ b/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) { diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index d6de2bc194e3cff7af7c1fc77acc8e6701511457..4359b49552b6e3a51dfb288131efeab46d8874ed 100644 --- a/crates/editor/src/editor_tests.rs +++ b/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")); }); } diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index d2d44fc8ada7360a0b8a8608ce916649b280abae..0cd84ec68257f7ab1e6054ab7f2464fb09113298 100644 --- a/crates/editor/src/items.rs +++ b/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 => { diff --git a/crates/project/src/search_history.rs b/crates/project/src/search_history.rs index de3548e4d2670675d441a7bf40e595158e7d34a3..a3b0c0a1bc89ca0fe1f770c6d08b21d740943470 100644 --- a/crates/project/src/search_history.rs +++ b/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, + draft: Option, } 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 { + 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)?, diff --git a/crates/project/tests/integration/search_history.rs b/crates/project/tests/integration/search_history.rs index 4b2d2b90ef0b91d2ff768dcd1a44d2ccfdc529d4..c6dfbe717c9e794474cc6641e5af0a03e1d38860 100644 --- a/crates/project/tests/integration/search_history.rs +++ b/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")); diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 35cd25dc389d522fc2a3d0ed88b8e06a9e181e67..2ad994244aee372dc829d199d6859a9234e2f56f 100644 --- a/crates/search/src/buffer_search.rs +++ b/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 { 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, ) { + 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, ) { + 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, ¤t_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) { diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 2c86c67f2574364698f4ee6d24eaf8aa0da5882f..97c6cbad52e00d991dca3cb41d118815d335e5ae 100644 --- a/crates/search/src/project_search.rs +++ b/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), ¤t_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] diff --git a/crates/search/src/search_bar.rs b/crates/search/src/search_bar.rs index 690b2eb927ce7384b7e6e313aeb5c825c544cdc9..436f70d6545a7eaaee23564058fb600fe387b739 100644 --- a/crates/search/src/search_bar.rs +++ b/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, + 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()