diff --git a/assets/settings/default.json b/assets/settings/default.json index 9c4e72d27a2877130577aaddb03a849b50d4aff3..997c3468002c416785c93edafbd3e2af07288e22 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -262,6 +262,8 @@ // to both the horizontal and vertical delta values while scrolling. "scroll_sensitivity": 1.0, "relative_line_numbers": false, + // If 'search_wrap' is disabled, search result do not wrap around the end of the file. + "search_wrap": true, // When to populate a new search's query based on the text under the cursor. // This setting can take the following three values: // diff --git a/crates/editor/src/editor_settings.rs b/crates/editor/src/editor_settings.rs index 28da3efa6aea7be8411195c8e88247940e241c60..fd7a21cd0ba6369a30f6f485310a6eccc20cbc55 100644 --- a/crates/editor/src/editor_settings.rs +++ b/crates/editor/src/editor_settings.rs @@ -25,6 +25,7 @@ pub struct EditorSettings { pub expand_excerpt_lines: u32, #[serde(default)] pub double_click_in_multibuffer: DoubleClickInMultibuffer, + pub search_wrap: bool, #[serde(default)] pub jupyter: Jupyter, } @@ -228,6 +229,10 @@ pub struct EditorSettingsContent { /// /// Default: select pub double_click_in_multibuffer: Option, + /// Whether the editor search results will loop + /// + /// Default: true + pub search_wrap: Option, /// Jupyter REPL settings. pub jupyter: Option, diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index ad737990fa0e146fe8fc3f0e2d4351e5507dc96a..d0669b4cfca52a66bd8bd6192b303ea9c83c23aa 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -9,7 +9,7 @@ use any_vec::AnyVec; use collections::HashMap; use editor::{ actions::{Tab, TabPrev}, - DisplayPoint, Editor, EditorElement, EditorStyle, + DisplayPoint, Editor, EditorElement, EditorSettings, EditorStyle, }; use futures::channel::oneshot; use gpui::{ @@ -777,6 +777,15 @@ impl BufferSearchBar { .get(&searchable_item.downgrade()) .filter(|matches| !matches.is_empty()) { + // If 'wrapscan' is disabled, searches do not wrap around the end of the file. + if !EditorSettings::get_global(cx).search_wrap { + if (direction == Direction::Next && index + count >= matches.len()) + || (direction == Direction::Prev && index < count) + { + crate::show_no_more_matches(cx); + return; + } + } let new_match_index = searchable_item .match_index_for_direction(matches, index, direction, count, cx); diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 6a546fbec9b850687370104cc073eafa28628a8e..82fc0a926cdcf935ca4a77c42f22a2b57c46a467 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -8,7 +8,8 @@ use editor::{ actions::SelectAll, items::active_match_index, scroll::{Autoscroll, Axis}, - Anchor, Editor, EditorElement, EditorEvent, EditorStyle, MultiBuffer, MAX_TAB_TITLE_LEN, + Anchor, Editor, EditorElement, EditorEvent, EditorSettings, EditorStyle, MultiBuffer, + MAX_TAB_TITLE_LEN, }; use gpui::{ actions, div, Action, AnyElement, AnyView, AppContext, Context as _, Element, EntityId, @@ -968,6 +969,16 @@ impl ProjectSearchView { fn select_match(&mut self, direction: Direction, cx: &mut ViewContext) { if let Some(index) = self.active_match_index { let match_ranges = self.model.read(cx).match_ranges.clone(); + + if !EditorSettings::get_global(cx).search_wrap { + if (direction == Direction::Next && index + 1 >= match_ranges.len()) + || (direction == Direction::Prev && index == 0) + { + crate::show_no_more_matches(cx); + return; + } + } + let new_index = self.results_editor.update(cx, |editor, cx| { editor.match_index_for_direction(&match_ranges, index, direction, 1, cx) }); diff --git a/crates/search/src/search.rs b/crates/search/src/search.rs index 220863dfce64b3d2af4ef28f983ae389860c1448..6a6c60fd0792b45d8eb4806f390c5700ca42fe5b 100644 --- a/crates/search/src/search.rs +++ b/crates/search/src/search.rs @@ -5,6 +5,8 @@ use project::search::SearchQuery; pub use project_search::ProjectSearchView; use ui::{prelude::*, Tooltip}; use ui::{ButtonStyle, IconButton}; +use workspace::notifications::NotificationId; +use workspace::{Toast, Workspace}; pub mod buffer_search; pub mod project_search; @@ -107,3 +109,21 @@ impl SearchOptions { }) } } + +pub(crate) fn show_no_more_matches(cx: &mut WindowContext) { + cx.defer(|cx| { + struct NotifType(); + let notification_id = NotificationId::unique::(); + let Some(workspace) = cx.window_handle().downcast::() else { + return; + }; + workspace + .update(cx, |workspace, cx| { + workspace.show_toast( + Toast::new(notification_id.clone(), "No more matches").autohide(), + cx, + ); + }) + .ok(); + }); +} diff --git a/crates/vim/src/normal/search.rs b/crates/vim/src/normal/search.rs index c579d8ff7e322fb6540d365aef236025b9596536..ab69d9dbc64338a1d03ab69abfa21515f237b147 100644 --- a/crates/vim/src/normal/search.rs +++ b/crates/vim/src/normal/search.rs @@ -526,14 +526,15 @@ fn parse_replace_all(query: &str) -> Replacement { mod test { use std::time::Duration; - use editor::{display_map::DisplayRow, DisplayPoint}; - use indoc::indoc; - use search::BufferSearchBar; - use crate::{ state::Mode, test::{NeovimBackedTestContext, VimTestContext}, }; + use editor::EditorSettings; + use editor::{display_map::DisplayRow, DisplayPoint}; + use indoc::indoc; + use search::BufferSearchBar; + use settings::SettingsStore; #[gpui::test] async fn test_move_to_next(cx: &mut gpui::TestAppContext) { @@ -572,6 +573,44 @@ mod test { cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal); } + #[gpui::test] + async fn test_move_to_next_with_no_search_wrap(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.update_global(|store: &mut SettingsStore, cx| { + store.update_user_settings::(cx, |s| s.search_wrap = Some(false)); + }); + + cx.set_state("ˇhi\nhigh\nhi\n", Mode::Normal); + + cx.simulate_keystrokes("*"); + cx.run_until_parked(); + cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal); + + cx.simulate_keystrokes("*"); + cx.run_until_parked(); + cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal); + + cx.simulate_keystrokes("#"); + cx.run_until_parked(); + cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal); + + cx.simulate_keystrokes("3 *"); + cx.run_until_parked(); + cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal); + + cx.simulate_keystrokes("g *"); + cx.run_until_parked(); + cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal); + + cx.simulate_keystrokes("n"); + cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal); + + cx.simulate_keystrokes("g #"); + cx.run_until_parked(); + cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal); + } + #[gpui::test] async fn test_search(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; @@ -649,6 +688,27 @@ mod test { cx.assert_editor_state("«oneˇ» two one"); cx.simulate_keystrokes("*"); cx.assert_state("one two ˇone", Mode::Normal); + + // check that searching with unable search wrap + cx.update_global(|store: &mut SettingsStore, cx| { + store.update_user_settings::(cx, |s| s.search_wrap = Some(false)); + }); + cx.set_state("aa\nbˇb\ncc\ncc\ncc\n", Mode::Normal); + cx.simulate_keystrokes("/ c c enter"); + + cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal); + + // n to go to next/N to go to previous + cx.simulate_keystrokes("n"); + cx.assert_state("aa\nbb\ncc\nˇcc\ncc\n", Mode::Normal); + cx.simulate_keystrokes("shift-n"); + cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal); + + // ? to go to previous + cx.simulate_keystrokes("? enter"); + cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal); + cx.simulate_keystrokes("? enter"); + cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal); } #[gpui::test] diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index d54ec8bf9dd14dd2eaa95ba07dd1414b4f23aa06..f5935d49734b64f9e0b6d9a4df45f93d5422383f 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -7,7 +7,7 @@ use gpui::{ }; use language::DiagnosticSeverity; -use std::{any::TypeId, ops::DerefMut}; +use std::{any::TypeId, ops::DerefMut, time::Duration}; use ui::{prelude::*, Tooltip}; use util::ResultExt; @@ -174,7 +174,7 @@ impl Workspace { pub fn show_toast(&mut self, toast: Toast, cx: &mut ViewContext) { self.dismiss_notification(&toast.id, cx); - self.show_notification(toast.id, cx, |cx| { + self.show_notification(toast.id.clone(), cx, |cx| { cx.new_view(|_cx| match toast.on_click.as_ref() { Some((click_msg, on_click)) => { let on_click = on_click.clone(); @@ -184,7 +184,20 @@ impl Workspace { } None => simple_message_notification::MessageNotification::new(toast.msg.clone()), }) - }) + }); + if toast.autohide { + cx.spawn(|workspace, mut cx| async move { + cx.background_executor() + .timer(Duration::from_millis(5000)) + .await; + workspace + .update(&mut cx, |workspace, cx| { + workspace.dismiss_toast(&toast.id, cx) + }) + .ok(); + }) + .detach(); + } } pub fn dismiss_toast(&mut self, id: &NotificationId, cx: &mut ViewContext) { diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index fb7c3e45bc5af5f58c0e0da84e5b5e0be6708a68..636198bdacce4192011a8c0cd81bcee175f4b6d2 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -218,9 +218,11 @@ impl_actions!( ] ); +#[derive(Clone)] pub struct Toast { id: NotificationId, msg: Cow<'static, str>, + autohide: bool, on_click: Option<(Cow<'static, str>, Arc)>, } @@ -230,6 +232,7 @@ impl Toast { id, msg: msg.into(), on_click: None, + autohide: false, } } @@ -241,6 +244,11 @@ impl Toast { self.on_click = Some((message.into(), Arc::new(on_click))); self } + + pub fn autohide(mut self) -> Self { + self.autohide = true; + self + } } impl PartialEq for Toast { @@ -251,16 +259,6 @@ impl PartialEq for Toast { } } -impl Clone for Toast { - fn clone(&self) -> Self { - Toast { - id: self.id.clone(), - msg: self.msg.clone(), - on_click: self.on_click.clone(), - } - } -} - #[derive(Debug, Default, Clone, Deserialize, PartialEq)] pub struct OpenTerminal { pub working_directory: PathBuf,