diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index a96d8032997390651acead7eb9eef6c7a5248151..9f9a8fff313bed23d69e3e699be7fbe563a0283f 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -278,6 +278,8 @@ pub fn init(cx: &mut AppContext) { }); } +pub struct SearchWithinRange; + trait InvalidationRegion { fn ranges(&self) -> &[Range]; } @@ -9264,6 +9266,18 @@ impl Editor { ) } + pub fn set_search_within_ranges( + &mut self, + ranges: &[Range], + cx: &mut ViewContext, + ) { + self.highlight_background::( + ranges, + |colors| colors.editor_document_highlight_read_background, + cx, + ) + } + pub fn highlight_background( &mut self, ranges: &[Range], diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index aa6b36d597ea64dca9eb4ebb4ea7d09e48fe9b05..2c10212ea98328d1da45c338184ddc95d5dea5bc 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -1,7 +1,7 @@ use crate::{ editor_settings::SeedQuerySetting, persistence::DB, scroll::ScrollAnchor, Anchor, Autoscroll, Editor, EditorEvent, EditorSettings, ExcerptId, ExcerptRange, MultiBuffer, MultiBufferSnapshot, - NavigationData, ToPoint as _, + NavigationData, SearchWithinRange, ToPoint as _, }; use anyhow::{anyhow, Context as _, Result}; use collections::HashSet; @@ -16,12 +16,14 @@ use language::{ proto::serialize_anchor as serialize_text_anchor, Bias, Buffer, CharKind, OffsetRangeExt, Point, SelectionGoal, }; +use multi_buffer::AnchorRangeExt; use project::{search::SearchQuery, FormatTrigger, Item as _, Project, ProjectPath}; use rpc::proto::{self, update_view, PeerId}; use settings::Settings; use workspace::item::{ItemSettings, TabContentParams}; use std::{ + any::TypeId, borrow::Cow, cmp::{self, Ordering}, iter, @@ -999,6 +1001,10 @@ impl SearchableItem for Editor { ); } + fn has_filtered_search_ranges(&mut self) -> bool { + self.has_background_highlights::() + } + fn query_suggestion(&mut self, cx: &mut ViewContext) -> String { let setting = EditorSettings::get_global(cx).seed_search_query_from_cursor; let snapshot = &self.snapshot(cx).buffer_snapshot; @@ -1123,18 +1129,37 @@ impl SearchableItem for Editor { cx: &mut ViewContext, ) -> Task>> { let buffer = self.buffer().read(cx).snapshot(cx); + let search_within_ranges = self + .background_highlights + .get(&TypeId::of::()) + .map(|(_color, ranges)| { + ranges + .iter() + .map(|range| range.to_offset(&buffer)) + .collect::>() + }); cx.background_executor().spawn(async move { let mut ranges = Vec::new(); if let Some((_, _, excerpt_buffer)) = buffer.as_singleton() { - ranges.extend( - query - .search(excerpt_buffer, None) - .await - .into_iter() - .map(|range| { - buffer.anchor_after(range.start)..buffer.anchor_before(range.end) - }), - ); + if let Some(search_within_ranges) = search_within_ranges { + for range in search_within_ranges { + let offset = range.start; + ranges.extend( + query + .search(excerpt_buffer, Some(range)) + .await + .into_iter() + .map(|range| { + buffer.anchor_after(range.start + offset) + ..buffer.anchor_before(range.end + offset) + }), + ); + } + } else { + ranges.extend(query.search(excerpt_buffer, None).await.into_iter().map( + |range| buffer.anchor_after(range.start)..buffer.anchor_before(range.end), + )); + } } else { for excerpt in buffer.excerpt_boundaries_in_range(0..buffer.len()) { let excerpt_range = excerpt.range.context.to_offset(&excerpt.buffer); diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index d8623d9c750c12d0c98c958be79e9df59ac03423..37516265164250efb7f14431fd062e20d2219fac 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -8,7 +8,7 @@ use crate::{ motion::{EndOfDocument, Motion, StartOfDocument}, normal::{ move_cursor, - search::{FindCommand, ReplaceCommand}, + search::{range_regex, FindCommand, ReplaceCommand}, JoinLines, }, state::Mode, @@ -340,6 +340,14 @@ pub fn command_interceptor(mut query: &str, cx: &AppContext) -> Option() { (query, GoToLine { line }.boxed_clone()) + } else if range_regex().is_match(query) { + ( + query, + ReplaceCommand { + query: query.to_string(), + } + .boxed_clone(), + ) } else { return None; } diff --git a/crates/vim/src/normal/search.rs b/crates/vim/src/normal/search.rs index b2670ffdbcacaa6b3ec2419ae3db3323c6304ca7..c819cf42651f10521682ef0776ff3094203e9466 100644 --- a/crates/vim/src/normal/search.rs +++ b/crates/vim/src/normal/search.rs @@ -1,4 +1,8 @@ +use std::{ops::Range, sync::OnceLock, time::Duration}; + use gpui::{actions, impl_actions, ViewContext}; +use language::Point; +use regex::Regex; use search::{buffer_search, BufferSearchBar, SearchOptions}; use serde_derive::Deserialize; use workspace::{searchable::Direction, Workspace}; @@ -47,6 +51,7 @@ struct Replacement { replacement: String, should_replace_all: bool, is_case_sensitive: bool, + range: Option>, } actions!(vim, [SearchSubmit, MoveToNextMatch, MoveToPrevMatch]); @@ -55,6 +60,11 @@ impl_actions!( [FindCommand, ReplaceCommand, Search, MoveToPrev, MoveToNext] ); +static RANGE_REGEX: OnceLock = OnceLock::new(); +pub(crate) fn range_regex() -> &'static Regex { + RANGE_REGEX.get_or_init(|| Regex::new(r"^(\d+),(\d+)s(.*)").unwrap()) +} + pub(crate) fn register(workspace: &mut Workspace, _: &mut ViewContext) { workspace.register_action(move_to_next); workspace.register_action(move_to_prev); @@ -329,6 +339,22 @@ fn replace_command( ) { let replacement = parse_replace_all(&action.query); let pane = workspace.active_pane().clone(); + let mut editor = Vim::read(cx) + .active_editor + .as_ref() + .and_then(|editor| editor.upgrade()); + if let Some(range) = &replacement.range { + if let Some(editor) = editor.as_mut() { + editor.update(cx, |editor, cx| { + let snapshot = &editor.snapshot(cx).buffer_snapshot; + let range = snapshot + .anchor_before(Point::new(range.start.saturating_sub(1) as u32, 0)) + ..snapshot.anchor_before(Point::new(range.end as u32, 0)); + + editor.set_search_within_ranges(&[range], cx) + }) + } + } pane.update(cx, |pane, cx| { let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() else { return; @@ -359,6 +385,19 @@ fn replace_command( if replacement.should_replace_all { search_bar.select_last_match(cx); search_bar.replace_all(&Default::default(), cx); + if let Some(editor) = editor { + cx.spawn(|_, mut cx| async move { + cx.background_executor() + .timer(Duration::from_millis(200)) + .await; + editor + .update(&mut cx, |editor, cx| { + editor.set_search_within_ranges(&[], cx) + }) + .ok(); + }) + .detach(); + } Vim::update(cx, |vim, cx| { move_cursor( vim, @@ -383,7 +422,20 @@ fn replace_command( // and convert \0..\9 to $0..$9 in the replacement so that common idioms work. fn parse_replace_all(query: &str) -> Replacement { let mut chars = query.chars(); - if Some('%') != chars.next() || Some('s') != chars.next() { + let mut range = None; + let maybe_line_range_and_rest: Option<(Range, &str)> = + range_regex().captures(query).map(|captures| { + ( + captures.get(1).unwrap().as_str().parse().unwrap() + ..captures.get(2).unwrap().as_str().parse().unwrap(), + captures.get(3).unwrap().as_str(), + ) + }); + if maybe_line_range_and_rest.is_some() { + let (line_range, rest) = maybe_line_range_and_rest.unwrap(); + range = Some(line_range); + chars = rest.chars(); + } else if Some('%') != chars.next() || Some('s') != chars.next() { return Replacement::default(); } @@ -440,6 +492,7 @@ fn parse_replace_all(query: &str) -> Replacement { replacement, should_replace_all: true, is_case_sensitive: true, + range, }; for c in flags.chars() { @@ -662,4 +715,36 @@ mod test { }) .await; } + + // cargo test -p vim --features neovim test_replace_with_range + #[gpui::test] + async fn test_replace_with_range(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state(indoc! { + "ˇa + a + a + a + a + a + a + " + }) + .await; + cx.simulate_shared_keystrokes([":", "2", ",", "5", "s", "/", "a", "/", "b"]) + .await; + cx.simulate_shared_keystrokes(["enter"]).await; + cx.assert_shared_state(indoc! { + "a + b + b + b + ˇb + a + a + " + }) + .await; + } } diff --git a/crates/vim/test_data/test_replace_with_range.json b/crates/vim/test_data/test_replace_with_range.json new file mode 100644 index 0000000000000000000000000000000000000000..46338719e99188d97330dabdb18c3cd9984dd3ef --- /dev/null +++ b/crates/vim/test_data/test_replace_with_range.json @@ -0,0 +1,12 @@ +{"Put":{"state":"ˇa\na\na\na\na\na\na\n "}} +{"Key":":"} +{"Key":"2"} +{"Key":","} +{"Key":"5"} +{"Key":"s"} +{"Key":"/"} +{"Key":"a"} +{"Key":"/"} +{"Key":"b"} +{"Key":"enter"} +{"Get":{"state":"a\nb\nb\nb\nˇb\na\na\n ","mode":"Normal"}} diff --git a/crates/workspace/src/searchable.rs b/crates/workspace/src/searchable.rs index 36df333fb37f1313b4be67306d1324e981bde6b8..950efde9e253241938e758db6946938952f80ad9 100644 --- a/crates/workspace/src/searchable.rs +++ b/crates/workspace/src/searchable.rs @@ -57,6 +57,10 @@ pub trait SearchableItem: Item + EventEmitter { fn search_bar_visibility_changed(&mut self, _visible: bool, _cx: &mut ViewContext) {} + fn has_filtered_search_ranges(&mut self) -> bool { + false + } + fn clear_matches(&mut self, cx: &mut ViewContext); fn update_matches(&mut self, matches: &[Self::Match], cx: &mut ViewContext); fn query_suggestion(&mut self, cx: &mut ViewContext) -> String;