Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/assistant_tools/src/regex_search_tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ impl Tool for RegexSearchTool {
false,
false,
false,
false,
PathMatcher::default(),
PathMatcher::default(),
None,
Expand Down
1 change: 1 addition & 0 deletions crates/collab_ui/src/chat_panel/message_editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ static MENTIONS_SEARCH: LazyLock<SearchQuery> = LazyLock::new(|| {
false,
false,
false,
false,
Default::default(),
Default::default(),
None,
Expand Down
16 changes: 16 additions & 0 deletions crates/editor/src/items.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1535,8 +1535,24 @@ impl SearchableItem for Editor {
let text = self.buffer.read(cx);
let text = text.snapshot(cx);
let mut edits = vec![];
let mut last_point: Option<Point> = None;

for m in matches {
let point = m.start.to_point(&text);
let text = text.text_for_range(m.clone()).collect::<Vec<_>>();

// Check if the row for the current match is different from the last
// match. If that's not the case and we're still replacing matches
// in the same row/line, skip this match if the `one_match_per_line`
// option is enabled.
if last_point.is_none() {
last_point = Some(point);
} else if last_point.is_some() && point.row != last_point.unwrap().row {
last_point = Some(point);
} else if query.one_match_per_line().is_some_and(|enabled| enabled) {
continue;
}

let text: Cow<_> = if text.len() == 1 {
text.first().cloned().unwrap().into()
} else {
Expand Down
17 changes: 17 additions & 0 deletions crates/project/src/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ pub enum SearchQuery {
whole_word: bool,
case_sensitive: bool,
include_ignored: bool,
one_match_per_line: bool,
inner: SearchInputs,
},
}
Expand Down Expand Up @@ -116,6 +117,7 @@ impl SearchQuery {
whole_word: bool,
case_sensitive: bool,
include_ignored: bool,
one_match_per_line: bool,
files_to_include: PathMatcher,
files_to_exclude: PathMatcher,
buffers: Option<Vec<Entity<Buffer>>>,
Expand Down Expand Up @@ -156,6 +158,7 @@ impl SearchQuery {
case_sensitive,
include_ignored,
inner,
one_match_per_line,
})
}

Expand All @@ -166,6 +169,7 @@ impl SearchQuery {
message.whole_word,
message.case_sensitive,
message.include_ignored,
false,
deserialize_path_matches(&message.files_to_include)?,
deserialize_path_matches(&message.files_to_exclude)?,
None, // search opened only don't need search remote
Expand Down Expand Up @@ -459,6 +463,19 @@ impl SearchQuery {
Self::Regex { inner, .. } | Self::Text { inner, .. } => inner,
}
}

/// Whether this search should replace only one match per line, instead of
/// all matches.
/// Returns `None` for text searches, as only regex searches support this
/// option.
pub fn one_match_per_line(&self) -> Option<bool> {
match self {
Self::Regex {
one_match_per_line, ..
} => Some(*one_match_per_line),
Self::Text { .. } => None,
}
}
}

pub fn deserialize_path_matches(glob_set: &str) -> anyhow::Result<PathMatcher> {
Expand Down
2 changes: 2 additions & 0 deletions crates/search/src/buffer_search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1231,6 +1231,8 @@ impl BufferSearchBar {
self.search_options.contains(SearchOptions::WHOLE_WORD),
self.search_options.contains(SearchOptions::CASE_SENSITIVE),
false,
self.search_options
.contains(SearchOptions::ONE_MATCH_PER_LINE),
Default::default(),
Default::default(),
None,
Expand Down
2 changes: 2 additions & 0 deletions crates/search/src/project_search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1053,6 +1053,8 @@ impl ProjectSearchView {
self.search_options.contains(SearchOptions::WHOLE_WORD),
self.search_options.contains(SearchOptions::CASE_SENSITIVE),
self.search_options.contains(SearchOptions::INCLUDE_IGNORED),
self.search_options
.contains(SearchOptions::ONE_MATCH_PER_LINE),
included_files,
excluded_files,
open_buffers,
Expand Down
1 change: 1 addition & 0 deletions crates/search/src/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ bitflags! {
const CASE_SENSITIVE = 0b010;
const INCLUDE_IGNORED = 0b100;
const REGEX = 0b1000;
const ONE_MATCH_PER_LINE = 0b100000;
/// If set, reverse direction when finding the active match
const BACKWARDS = 0b10000;
}
Expand Down
65 changes: 39 additions & 26 deletions crates/vim/src/normal/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,8 @@ impl Vim {
}
let vim = cx.entity().clone();
pane.update(cx, |pane, cx| {
let mut options = SearchOptions::REGEX;

let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() else {
return;
};
Expand All @@ -453,7 +455,6 @@ impl Vim {
return None;
}

let mut options = SearchOptions::REGEX;
if replacement.is_case_sensitive {
options.set(SearchOptions::CASE_SENSITIVE, true)
}
Expand All @@ -468,6 +469,11 @@ impl Vim {
search_bar.is_contains_uppercase(&search),
);
}

if !replacement.should_replace_all {
options.set(SearchOptions::ONE_MATCH_PER_LINE, true);
}

search_bar.set_replacement(Some(&replacement.replacement), cx);
Some(search_bar.search(&search, Some(options), window, cx))
});
Expand All @@ -476,29 +482,35 @@ impl Vim {
cx.spawn_in(window, async move |_, cx| {
search.await?;
search_bar.update_in(cx, |search_bar, window, cx| {
if replacement.should_replace_all {
search_bar.select_last_match(window, cx);
search_bar.replace_all(&Default::default(), window, cx);
cx.spawn(async move |_, cx| {
cx.background_executor()
.timer(Duration::from_millis(200))
.await;
editor
.update(cx, |editor, cx| editor.clear_search_within_ranges(cx))
.ok();
})
.detach();
vim.update(cx, |vim, cx| {
vim.move_cursor(
Motion::StartOfLine {
display_lines: false,
},
None,
window,
cx,
)
});
}
search_bar.select_last_match(window, cx);
search_bar.replace_all(&Default::default(), window, cx);

cx.spawn(async move |_, cx| {
cx.background_executor()
.timer(Duration::from_millis(200))
.await;
editor
.update(cx, |editor, cx| editor.clear_search_within_ranges(cx))
.ok();
})
.detach();
vim.update(cx, |vim, cx| {
vim.move_cursor(
Motion::StartOfLine {
display_lines: false,
},
None,
window,
cx,
)
});

// Disable the `ONE_MATCH_PER_LINE` search option when finished, as
// this is not properly supported outside of vim mode, and
// not disabling it makes the "Replace All Matches" button
// actually replace only the first match on each line.
options.set(SearchOptions::ONE_MATCH_PER_LINE, false);
search_bar.set_search_options(options, cx);
})?;
anyhow::Ok(())
})
Expand Down Expand Up @@ -564,15 +576,16 @@ impl Replacement {
let mut replacement = Replacement {
search,
replacement,
should_replace_all: true,
should_replace_all: false,
is_case_sensitive: true,
};

for c in flags.chars() {
match c {
'g' | 'I' => {}
'g' => replacement.should_replace_all = true,
'c' | 'n' => replacement.should_replace_all = false,
'i' => replacement.is_case_sensitive = false,
'I' => replacement.is_case_sensitive = true,
_ => {}
}
}
Expand Down