Detailed changes
@@ -278,6 +278,8 @@ pub fn init(cx: &mut AppContext) {
});
}
+pub struct SearchWithinRange;
+
trait InvalidationRegion {
fn ranges(&self) -> &[Range<Anchor>];
}
@@ -9264,6 +9266,18 @@ impl Editor {
)
}
+ pub fn set_search_within_ranges(
+ &mut self,
+ ranges: &[Range<Anchor>],
+ cx: &mut ViewContext<Self>,
+ ) {
+ self.highlight_background::<SearchWithinRange>(
+ ranges,
+ |colors| colors.editor_document_highlight_read_background,
+ cx,
+ )
+ }
+
pub fn highlight_background<T: 'static>(
&mut self,
ranges: &[Range<Anchor>],
@@ -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::<SearchWithinRange>()
+ }
+
fn query_suggestion(&mut self, cx: &mut ViewContext<Self>) -> 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<Self>,
) -> Task<Vec<Range<Anchor>>> {
let buffer = self.buffer().read(cx).snapshot(cx);
+ let search_within_ranges = self
+ .background_highlights
+ .get(&TypeId::of::<SearchWithinRange>())
+ .map(|(_color, ranges)| {
+ ranges
+ .iter()
+ .map(|range| range.to_offset(&buffer))
+ .collect::<Vec<_>>()
+ });
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);
@@ -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<CommandIn
)
} else if let Ok(line) = query.parse::<u32>() {
(query, GoToLine { line }.boxed_clone())
+ } else if range_regex().is_match(query) {
+ (
+ query,
+ ReplaceCommand {
+ query: query.to_string(),
+ }
+ .boxed_clone(),
+ )
} else {
return None;
}
@@ -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<Range<usize>>,
}
actions!(vim, [SearchSubmit, MoveToNextMatch, MoveToPrevMatch]);
@@ -55,6 +60,11 @@ impl_actions!(
[FindCommand, ReplaceCommand, Search, MoveToPrev, MoveToNext]
);
+static RANGE_REGEX: OnceLock<Regex> = 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>) {
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::<BufferSearchBar>() 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<usize>, &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;
+ }
}
@@ -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"}}
@@ -57,6 +57,10 @@ pub trait SearchableItem: Item + EventEmitter<SearchEvent> {
fn search_bar_visibility_changed(&mut self, _visible: bool, _cx: &mut ViewContext<Self>) {}
+ fn has_filtered_search_ranges(&mut self) -> bool {
+ false
+ }
+
fn clear_matches(&mut self, cx: &mut ViewContext<Self>);
fn update_matches(&mut self, matches: &[Self::Match], cx: &mut ViewContext<Self>);
fn query_suggestion(&mut self, cx: &mut ViewContext<Self>) -> String;