Make buffer search aware of search direction (#24974)

This solves a couple of issues with Vim search by making the search
buffer and `SearchableItem` aware of the direction of the search. If
`SearchOptions::BACKWARDS` is set, all operations will be reversed. By
making `SearchableItem` aware of the direction, the correct active match
can be selected when searching backward.

Fixes #22506. This PR does not fix the last problem in that issue, but
that one is also tracked in #8049.

Release Notes:

- Fixes incorrect behavior of backward search in Vim mode
This commit is contained in:
Nico Lehmann 2025-03-04 18:27:37 -08:00 committed by GitHub
parent ed13e05855
commit 229e853874
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 80 additions and 25 deletions

View file

@ -54,7 +54,7 @@ use ui::{
Tooltip, Tooltip,
}; };
use util::{maybe, ResultExt}; use util::{maybe, ResultExt};
use workspace::searchable::SearchableItemHandle; use workspace::searchable::{Direction, SearchableItemHandle};
use workspace::{ use workspace::{
item::{self, FollowableItem, Item, ItemHandle}, item::{self, FollowableItem, Item, ItemHandle},
notifications::NotificationId, notifications::NotificationId,
@ -3060,12 +3060,13 @@ impl SearchableItem for ContextEditor {
fn active_match_index( fn active_match_index(
&mut self, &mut self,
direction: Direction,
matches: &[Self::Match], matches: &[Self::Match],
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Option<usize> { ) -> Option<usize> {
self.editor.update(cx, |editor, cx| { self.editor.update(cx, |editor, cx| {
editor.active_match_index(matches, window, cx) editor.active_match_index(direction, matches, window, cx)
}) })
} }
} }

View file

@ -1589,11 +1589,13 @@ impl SearchableItem for Editor {
fn active_match_index( fn active_match_index(
&mut self, &mut self,
direction: Direction,
matches: &[Range<Anchor>], matches: &[Range<Anchor>],
_: &mut Window, _: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Option<usize> { ) -> Option<usize> {
active_match_index( active_match_index(
direction,
matches, matches,
&self.selections.newest_anchor().head(), &self.selections.newest_anchor().head(),
&self.buffer().read(cx).snapshot(cx), &self.buffer().read(cx).snapshot(cx),
@ -1606,6 +1608,7 @@ impl SearchableItem for Editor {
} }
pub fn active_match_index( pub fn active_match_index(
direction: Direction,
ranges: &[Range<Anchor>], ranges: &[Range<Anchor>],
cursor: &Anchor, cursor: &Anchor,
buffer: &MultiBufferSnapshot, buffer: &MultiBufferSnapshot,
@ -1613,7 +1616,7 @@ pub fn active_match_index(
if ranges.is_empty() { if ranges.is_empty() {
None None
} else { } else {
match ranges.binary_search_by(|probe| { let r = ranges.binary_search_by(|probe| {
if probe.end.cmp(cursor, buffer).is_lt() { if probe.end.cmp(cursor, buffer).is_lt() {
Ordering::Less Ordering::Less
} else if probe.start.cmp(cursor, buffer).is_gt() { } else if probe.start.cmp(cursor, buffer).is_gt() {
@ -1621,8 +1624,15 @@ pub fn active_match_index(
} else { } else {
Ordering::Equal Ordering::Equal
} }
}) { });
Ok(i) | Err(i) => Some(cmp::min(i, ranges.len() - 1)), match direction {
Direction::Prev => match r {
Ok(i) => Some(i),
Err(i) => Some(i.saturating_sub(1)),
},
Direction::Next => match r {
Ok(i) | Err(i) => Some(cmp::min(i, ranges.len() - 1)),
},
} }
} }
} }

View file

@ -16,7 +16,7 @@ use std::{borrow::Cow, sync::Arc};
use ui::{prelude::*, Button, Checkbox, ContextMenu, Label, PopoverMenu, ToggleState}; use ui::{prelude::*, Button, Checkbox, ContextMenu, Label, PopoverMenu, ToggleState};
use workspace::{ use workspace::{
item::{Item, ItemHandle}, item::{Item, ItemHandle},
searchable::{SearchEvent, SearchableItem, SearchableItemHandle}, searchable::{Direction, SearchEvent, SearchableItem, SearchableItemHandle},
SplitDirection, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, WorkspaceId, SplitDirection, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, WorkspaceId,
}; };
@ -1170,12 +1170,14 @@ impl SearchableItem for LspLogView {
} }
fn active_match_index( fn active_match_index(
&mut self, &mut self,
direction: Direction,
matches: &[Self::Match], matches: &[Self::Match],
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Option<usize> { ) -> Option<usize> {
self.editor self.editor.update(cx, |e, cx| {
.update(cx, |e, cx| e.active_match_index(matches, window, cx)) e.active_match_index(direction, matches, window, cx)
})
} }
} }

View file

@ -1306,7 +1306,16 @@ impl BufferSearchBar {
done_rx done_rx
} }
fn reverse_direction_if_backwards(&self, direction: Direction) -> Direction {
if self.search_options.contains(SearchOptions::BACKWARDS) {
direction.opposite()
} else {
direction
}
}
pub fn update_match_index(&mut self, window: &mut Window, cx: &mut Context<Self>) { pub fn update_match_index(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let direction = self.reverse_direction_if_backwards(Direction::Next);
let new_index = self let new_index = self
.active_searchable_item .active_searchable_item
.as_ref() .as_ref()
@ -1314,7 +1323,7 @@ impl BufferSearchBar {
let matches = self let matches = self
.searchable_items_with_matches .searchable_items_with_matches
.get(&searchable_item.downgrade())?; .get(&searchable_item.downgrade())?;
searchable_item.active_match_index(matches, window, cx) searchable_item.active_match_index(direction, matches, window, cx)
}); });
if new_index != self.active_match_index { if new_index != self.active_match_index {
self.active_match_index = new_index; self.active_match_index = new_index;

View file

@ -1240,6 +1240,7 @@ impl ProjectSearchView {
fn update_match_index(&mut self, cx: &mut Context<Self>) { fn update_match_index(&mut self, cx: &mut Context<Self>) {
let results_editor = self.results_editor.read(cx); let results_editor = self.results_editor.read(cx);
let new_index = active_match_index( let new_index = active_match_index(
Direction::Next,
&self.entity.read(cx).match_ranges, &self.entity.read(cx).match_ranges,
&results_editor.selections.newest_anchor().head(), &results_editor.selections.newest_anchor().head(),
&results_editor.buffer().read(cx).snapshot(cx), &results_editor.buffer().read(cx).snapshot(cx),

View file

@ -47,6 +47,8 @@ bitflags! {
const CASE_SENSITIVE = 0b010; const CASE_SENSITIVE = 0b010;
const INCLUDE_IGNORED = 0b100; const INCLUDE_IGNORED = 0b100;
const REGEX = 0b1000; const REGEX = 0b1000;
/// If set, reverse direction when finding the active match
const BACKWARDS = 0b10000;
} }
} }

View file

@ -41,7 +41,7 @@ use workspace::{
BreadcrumbText, Item, ItemEvent, SerializableItem, TabContentParams, TabTooltipContent, BreadcrumbText, Item, ItemEvent, SerializableItem, TabContentParams, TabTooltipContent,
}, },
register_serializable_item, register_serializable_item,
searchable::{SearchEvent, SearchOptions, SearchableItem, SearchableItemHandle}, searchable::{Direction, SearchEvent, SearchOptions, SearchableItem, SearchableItemHandle},
CloseActiveItem, NewCenterTerminal, NewTerminal, OpenVisible, ToolbarItemLocation, Workspace, CloseActiveItem, NewCenterTerminal, NewTerminal, OpenVisible, ToolbarItemLocation, Workspace,
WorkspaceId, WorkspaceId,
}; };
@ -1583,6 +1583,7 @@ impl SearchableItem for TerminalView {
/// Reports back to the search toolbar what the active match should be (the selection) /// Reports back to the search toolbar what the active match should be (the selection)
fn active_match_index( fn active_match_index(
&mut self, &mut self,
direction: Direction,
matches: &[Self::Match], matches: &[Self::Match],
_: &mut Window, _: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
@ -1593,19 +1594,36 @@ impl SearchableItem for TerminalView {
let res = if !matches.is_empty() { let res = if !matches.is_empty() {
if let Some(selection_head) = self.terminal().read(cx).selection_head { if let Some(selection_head) = self.terminal().read(cx).selection_head {
// If selection head is contained in a match. Return that match // If selection head is contained in a match. Return that match
if let Some(ix) = matches match direction {
.iter() Direction::Prev => {
.enumerate() // If no selection before selection head, return the first match
.find(|(_, search_match)| { Some(
search_match.contains(&selection_head) matches
|| search_match.start() > &selection_head .iter()
}) .enumerate()
.map(|(ix, _)| ix) .rev()
{ .find(|(_, search_match)| {
Some(ix) search_match.contains(&selection_head)
} else { || search_match.start() < &selection_head
// If no selection after selection head, return the last match })
Some(matches.len().saturating_sub(1)) .map(|(ix, _)| ix)
.unwrap_or(0),
)
}
Direction::Next => {
// If no selection after selection head, return the last match
Some(
matches
.iter()
.enumerate()
.find(|(_, search_match)| {
search_match.contains(&selection_head)
|| search_match.start() > &selection_head
})
.map(|(ix, _)| ix)
.unwrap_or(matches.len().saturating_sub(1)),
)
}
} }
} else { } else {
// Matches found but no active selection, return the first last one (closest to cursor) // Matches found but no active selection, return the first last one (closest to cursor)

View file

@ -154,6 +154,9 @@ impl Vim {
if action.regex { if action.regex {
options |= SearchOptions::REGEX; options |= SearchOptions::REGEX;
} }
if action.backwards {
options |= SearchOptions::BACKWARDS;
}
search_bar.set_search_options(options, cx); search_bar.set_search_options(options, cx);
let prior_mode = if self.temp_mode { let prior_mode = if self.temp_mode {
Mode::Insert Mode::Insert
@ -198,7 +201,7 @@ impl Vim {
.last() .last()
.map_or(true, |range| range.start != new_head); .map_or(true, |range| range.start != new_head);
if is_different_head && self.search.direction == Direction::Next { if is_different_head {
count = count.saturating_sub(1) count = count.saturating_sub(1)
} }
self.search.count = 1; self.search.count = 1;
@ -743,6 +746,12 @@ mod test {
cx.simulate_keystrokes("*"); cx.simulate_keystrokes("*");
cx.assert_state("one two ˇone", Mode::Normal); cx.assert_state("one two ˇone", Mode::Normal);
// check that a backward search after last match works correctly
cx.set_state("aa\naa\nbbˇ", Mode::Normal);
cx.simulate_keystrokes("? a a");
cx.simulate_keystrokes("enter");
cx.assert_state("aa\nˇaa\nbb", Mode::Normal);
// check that searching with unable search wrap // check that searching with unable search wrap
cx.update_global(|store: &mut SettingsStore, cx| { cx.update_global(|store: &mut SettingsStore, cx| {
store.update_user_settings::<EditorSettings>(cx, |s| s.search_wrap = Some(false)); store.update_user_settings::<EditorSettings>(cx, |s| s.search_wrap = Some(false));

View file

@ -150,6 +150,7 @@ pub trait SearchableItem: Item + EventEmitter<SearchEvent> {
) -> Task<Vec<Self::Match>>; ) -> Task<Vec<Self::Match>>;
fn active_match_index( fn active_match_index(
&mut self, &mut self,
direction: Direction,
matches: &[Self::Match], matches: &[Self::Match],
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
@ -208,6 +209,7 @@ pub trait SearchableItemHandle: ItemHandle {
) -> Task<AnyVec<dyn Send>>; ) -> Task<AnyVec<dyn Send>>;
fn active_match_index( fn active_match_index(
&self, &self,
direction: Direction,
matches: &AnyVec<dyn Send>, matches: &AnyVec<dyn Send>,
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
@ -315,13 +317,14 @@ impl<T: SearchableItem> SearchableItemHandle for Entity<T> {
} }
fn active_match_index( fn active_match_index(
&self, &self,
direction: Direction,
matches: &AnyVec<dyn Send>, matches: &AnyVec<dyn Send>,
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) -> Option<usize> { ) -> Option<usize> {
let matches = matches.downcast_ref()?; let matches = matches.downcast_ref()?;
self.update(cx, |this, cx| { self.update(cx, |this, cx| {
this.active_match_index(matches.as_slice(), window, cx) this.active_match_index(direction, matches.as_slice(), window, cx)
}) })
} }