editor: Improve search to select first match in visible range. (#1258)

This commit is contained in:
Jason Lee 2025-09-18 16:07:35 +08:00 committed by GitHub
parent a942c57627
commit 51efa766ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 221 additions and 86 deletions

51
Cargo.lock generated
View file

@ -2979,6 +2979,19 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "git2"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2deb07a133b1520dc1a5690e9bd08950108873d7ed5de38dcc74d3b5ebffa110"
dependencies = [
"bitflags 2.9.1",
"libc",
"libgit2-sys",
"log",
"url",
]
[[package]] [[package]]
name = "glib" name = "glib"
version = "0.18.5" version = "0.18.5"
@ -3118,6 +3131,7 @@ dependencies = [
"as-raw-xcb-connection", "as-raw-xcb-connection",
"ashpd 0.11.0", "ashpd 0.11.0",
"async-task", "async-task",
"backtrace",
"bindgen 0.71.1", "bindgen 0.71.1",
"blade-graphics", "blade-graphics",
"blade-macros", "blade-macros",
@ -4284,6 +4298,18 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "libgit2-sys"
version = "0.18.2+1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c42fe03df2bd3c53a3a9c7317ad91d80c81cd1fb0caec8d7cc4cd2bfa10c222"
dependencies = [
"cc",
"libc",
"libz-sys",
"pkg-config",
]
[[package]] [[package]]
name = "libloading" name = "libloading"
version = "0.8.8" version = "0.8.8"
@ -4311,6 +4337,18 @@ dependencies = [
"redox_syscall 0.5.17", "redox_syscall 0.5.17",
] ]
[[package]]
name = "libz-sys"
version = "1.1.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b70e7a7df205e92a1a4cd9aaae7898dac0aa555503cc0a649494d0d60e7651d"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.3.8" version = "0.3.8"
@ -9215,11 +9253,13 @@ dependencies = [
"dunce", "dunce",
"futures", "futures",
"futures-lite 1.13.0", "futures-lite 1.13.0",
"git2",
"globset", "globset",
"itertools 0.14.0", "itertools 0.14.0",
"libc", "libc",
"log", "log",
"nix 0.29.0", "nix 0.29.0",
"rand 0.9.2",
"regex", "regex",
"rust-embed", "rust-embed",
"schemars", "schemars",
@ -9232,10 +9272,21 @@ dependencies = [
"tempfile", "tempfile",
"tendril", "tendril",
"unicase", "unicase",
"util_macros",
"walkdir", "walkdir",
"workspace-hack", "workspace-hack",
] ]
[[package]]
name = "util_macros"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed.git#53b2f37452189870c93d4514604f903d5ed885d9"
dependencies = [
"quote",
"syn 2.0.105",
"workspace-hack",
]
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.18.0" version = "1.18.0"

View file

@ -48,7 +48,7 @@ tree-sitter-languages = [
] ]
[dependencies] [dependencies]
gpui.workspace = true gpui = { workspace = true, "features" = ["test-support"] }
sum_tree.workspace = true sum_tree.workspace = true
gpui_macros.workspace = true gpui_macros.workspace = true
rope.workspace = true rope.workspace = true

View file

@ -3,9 +3,9 @@ use rust_i18n::t;
use std::{ops::Range, rc::Rc}; use std::{ops::Range, rc::Rc};
use gpui::{ use gpui::{
actions, div, prelude::FluentBuilder as _, App, AppContext as _, Context, Empty, Entity, actions, canvas, div, prelude::FluentBuilder as _, App, AppContext as _, Context, Empty,
EntityInputHandler, FocusHandle, Focusable, Half, InteractiveElement as _, IntoElement, Entity, EntityInputHandler, FocusHandle, Focusable, Half, InteractiveElement as _, IntoElement,
KeyBinding, ParentElement as _, Render, Styled, Subscription, Window, KeyBinding, ParentElement as _, Pixels, Render, Styled, Subscription, Window,
}; };
use rope::Rope; use rope::Rope;
@ -14,7 +14,8 @@ use crate::{
button::{Button, ButtonVariants}, button::{Button, ButtonVariants},
h_flex, h_flex,
input::{Enter, Escape, IndentInline, InputEvent, InputState, RopeExt, Search, TextInput}, input::{Enter, Escape, IndentInline, InputEvent, InputState, RopeExt, Search, TextInput},
v_flex, ActiveTheme, IconName, Selectable, Sizable, label::Label,
v_flex, ActiveTheme, Disableable, IconName, Selectable, Sizable,
}; };
const KEY_CONTEXT: &'static str = "SearchPanel"; const KEY_CONTEXT: &'static str = "SearchPanel";
@ -103,6 +104,23 @@ impl SearchMatcher {
fn peek(&self) -> Option<Range<usize>> { fn peek(&self) -> Option<Range<usize>> {
self.matched_ranges.get(self.current_match_ix + 1).cloned() self.matched_ranges.get(self.current_match_ix + 1).cloned()
} }
fn label(&self) -> String {
if self.len() == 0 {
return "0/0".to_string();
}
format!("{}/{}", self.current_match_ix + 1, self.len())
}
/// Update the current match index based on the given offset.
fn update_cursor_by_offset(&mut self, offset: usize) {
for (ix, range) in self.matched_ranges.iter().enumerate() {
self.current_match_ix = ix;
if range.contains(&offset) || range.end >= offset {
return;
}
}
}
} }
impl Iterator for SearchMatcher { impl Iterator for SearchMatcher {
@ -141,12 +159,13 @@ impl DoubleEndedIterator for SearchMatcher {
} }
pub(super) struct SearchPanel { pub(super) struct SearchPanel {
text_state: Entity<InputState>, editor: Entity<InputState>,
search_input: Entity<InputState>, search_input: Entity<InputState>,
replace_input: Entity<InputState>, replace_input: Entity<InputState>,
case_insensitive: bool, case_insensitive: bool,
replace_mode: bool, replace_mode: bool,
matcher: SearchMatcher, matcher: SearchMatcher,
input_width: Pixels,
open: bool, open: bool,
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
@ -181,10 +200,10 @@ impl InputState {
}; };
let text = self.text.clone(); let text = self.text.clone();
let text_state = cx.entity(); let editor = cx.entity();
let selected_text = self.selected_text(); let selected_text = self.selected_text();
search_panel.update(cx, |this, cx| { search_panel.update(cx, |this, cx| {
this.text_state = text_state; this.editor = editor;
this.matcher.update(&text); this.matcher.update(&text);
this.show(&selected_text, window, cx); this.show(&selected_text, window, cx);
}); });
@ -194,34 +213,33 @@ impl InputState {
} }
impl SearchPanel { impl SearchPanel {
pub fn new(text_state: Entity<InputState>, window: &mut Window, cx: &mut App) -> Entity<Self> { pub fn new(editor: Entity<InputState>, window: &mut Window, cx: &mut App) -> Entity<Self> {
let search_input = cx.new(|cx| InputState::new(window, cx)); let search_input = cx.new(|cx| InputState::new(window, cx));
let replace_input = cx.new(|cx| InputState::new(window, cx)); let replace_input = cx.new(|cx| InputState::new(window, cx));
cx.new(|cx| { cx.new(|cx| {
let _subscriptions = vec![cx.subscribe( let _subscriptions =
&search_input, vec![
|this: &mut Self, search_input, ev: &InputEvent, cx| { cx.subscribe(&search_input, |this: &mut Self, _, ev: &InputEvent, cx| {
// Handle search input changes // Handle search input changes
match ev { match ev {
InputEvent::Change => { InputEvent::Change => {
let value = search_input.read(cx).value(); this.update_search_query(cx);
this.matcher }
.update_query(value.as_str(), this.case_insensitive); _ => {}
} }
_ => {} }),
} ];
},
)];
Self { Self {
text_state, editor,
search_input, search_input,
replace_input, replace_input,
case_insensitive: true, case_insensitive: true,
replace_mode: false, replace_mode: false,
matcher: SearchMatcher::new(), matcher: SearchMatcher::new(),
open: true, open: true,
input_width: Pixels::ZERO,
_subscriptions, _subscriptions,
} }
}) })
@ -238,24 +256,35 @@ impl SearchPanel {
self.search_input.update(cx, |this, cx| { self.search_input.update(cx, |this, cx| {
if selected_text.len() > 0 { if selected_text.len() > 0 {
// Set value will emit to update_search_query
this.set_value(selected_text.to_string(), window, cx); this.set_value(selected_text.to_string(), window, cx);
} }
this.select_all(&super::SelectAll, window, cx); this.select_all(&super::SelectAll, window, cx);
}); });
self.update_search(cx);
cx.notify();
} }
fn update_search(&mut self, cx: &mut Context<Self>) { fn update_search_query(&mut self, cx: &mut Context<Self>) {
let query = self.search_input.read(cx).value(); let query = self.search_input.read(cx).value();
let visible_range_offset = self
.editor
.read(cx)
.last_layout
.as_ref()
.map(|l| l.visible_range_offset.clone());
self.matcher self.matcher
.update_query(query.as_str(), self.case_insensitive); .update_query(query.as_str(), self.case_insensitive);
self.update_text_selection(cx);
if let Some(visible_range_offset) = visible_range_offset {
self.matcher
.update_cursor_by_offset(visible_range_offset.start);
}
cx.notify();
} }
pub(super) fn hide(&mut self, window: &mut Window, cx: &mut Context<Self>) { pub(super) fn hide(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.open = false; self.open = false;
self.text_state.read(cx).focus_handle.focus(window); self.editor.read(cx).focus_handle.focus(window);
cx.notify(); cx.notify();
} }
@ -272,32 +301,12 @@ impl SearchPanel {
} }
fn on_action_tab(&mut self, _: &IndentInline, window: &mut Window, cx: &mut Context<Self>) { fn on_action_tab(&mut self, _: &IndentInline, window: &mut Window, cx: &mut Context<Self>) {
self.text_state.focus_handle(cx).focus(window); self.editor.focus_handle(cx).focus(window);
}
fn update_text_selection(&mut self, cx: &mut Context<Self>) {
if let Some(range) = self
.matcher
.matched_ranges
.get(self.matcher.current_match_ix)
.cloned()
{
let state = self.text_state.clone();
cx.spawn(async move |_, cx| {
_ = cx.update(|cx| {
state.update(cx, |state, cx| {
state.selected_range = range.into();
cx.notify();
});
});
})
.detach();
}
} }
fn prev(&mut self, _: &mut Window, cx: &mut Context<Self>) { fn prev(&mut self, _: &mut Window, cx: &mut Context<Self>) {
if let Some(range) = self.matcher.next_back() { if let Some(range) = self.matcher.next_back() {
self.text_state.update(cx, |state, cx| { self.editor.update(cx, |state, cx| {
state.scroll_to(range.start, cx); state.scroll_to(range.start, cx);
}); });
} }
@ -305,7 +314,7 @@ impl SearchPanel {
fn next(&mut self, _: &mut Window, cx: &mut Context<Self>) { fn next(&mut self, _: &mut Window, cx: &mut Context<Self>) {
if let Some(range) = self.matcher.next() { if let Some(range) = self.matcher.next() {
self.text_state.update(cx, |state, cx| { self.editor.update(cx, |state, cx| {
state.scroll_to(range.end, cx); state.scroll_to(range.end, cx);
}); });
} }
@ -328,7 +337,7 @@ impl SearchPanel {
.get(self.matcher.current_match_ix) .get(self.matcher.current_match_ix)
.cloned() .cloned()
{ {
let text_state = self.text_state.clone(); let text_state = self.editor.clone();
let next_range = self.matcher.peek().unwrap_or(range.clone()); let next_range = self.matcher.peek().unwrap_or(range.clone());
cx.spawn_in(window, async move |_, cx| { cx.spawn_in(window, async move |_, cx| {
@ -357,10 +366,10 @@ impl SearchPanel {
return; return;
} }
let text_state = self.text_state.clone(); let editor = self.editor.clone();
cx.spawn_in(window, async move |_, cx| { cx.spawn_in(window, async move |_, cx| {
cx.update(|window, cx| { cx.update(|window, cx| {
text_state.update(cx, |state, cx| { editor.update(cx, |state, cx| {
// Replace from the end to avoid messing up the ranges. // Replace from the end to avoid messing up the ranges.
let mut rope = state.text.clone(); let mut rope = state.text.clone();
for range in ranges.iter().rev() { for range in ranges.iter().rev() {
@ -392,6 +401,8 @@ impl Render for SearchPanel {
return Empty.into_any_element(); return Empty.into_any_element();
} }
let has_matches = self.matcher.len() > 0;
v_flex() v_flex()
.id("search-panel") .id("search-panel")
.occlude() .occlude()
@ -416,27 +427,44 @@ impl Render for SearchPanel {
.w_full() .w_full()
.gap_2() .gap_2()
.child( .child(
div().flex_1().gap_1().child( div()
TextInput::new(&self.search_input) .flex_1()
.focus_bordered(false) .gap_1()
.suffix( .child(
Button::new("case-insensitive") TextInput::new(&self.search_input)
.selected(!self.case_insensitive) .focus_bordered(false)
.xsmall() .suffix(
.compact() Button::new("case-insensitive")
.ghost() .selected(!self.case_insensitive)
.icon(IconName::CaseSensitive) .xsmall()
.on_click(cx.listener(|this, _, _, cx| { .compact()
this.case_insensitive = !this.case_insensitive; .ghost()
this.update_search(cx); .icon(IconName::CaseSensitive)
cx.notify(); .on_click(cx.listener(|this, _, _, cx| {
})), this.case_insensitive = !this.case_insensitive;
this.update_search_query(cx);
cx.notify();
})),
)
.small()
.w_full()
.shadow_none(),
)
.child(
canvas(
{
let view = cx.entity();
move |bounds, _, cx| {
view.update(cx, |r, _| {
r.input_width = bounds.size.width
})
}
},
|_, _, _, _| {},
) )
.small() .absolute()
.w_full() .size_full(),
.cleanable() ),
.shadow_none(),
),
) )
.child( .child(
Button::new("replace-mode") Button::new("replace-mode")
@ -446,7 +474,11 @@ impl Render for SearchPanel {
.selected(self.replace_mode) .selected(self.replace_mode)
.on_click(cx.listener(|this, _, window, cx| { .on_click(cx.listener(|this, _, window, cx| {
this.replace_mode = !this.replace_mode; this.replace_mode = !this.replace_mode;
this.replace_input.read(cx).focus_handle.focus(window); if this.replace_mode {
this.replace_input.read(cx).focus_handle.focus(window);
} else {
this.search_input.read(cx).focus_handle.focus(window);
}
cx.notify(); cx.notify();
})), })),
) )
@ -455,6 +487,7 @@ impl Render for SearchPanel {
.xsmall() .xsmall()
.ghost() .ghost()
.icon(IconName::ChevronLeft) .icon(IconName::ChevronLeft)
.disabled(!has_matches)
.on_click(cx.listener(|this, _, window, cx| { .on_click(cx.listener(|this, _, window, cx| {
this.prev(window, cx); this.prev(window, cx);
})), })),
@ -464,11 +497,20 @@ impl Render for SearchPanel {
.xsmall() .xsmall()
.ghost() .ghost()
.icon(IconName::ChevronRight) .icon(IconName::ChevronRight)
.disabled(!has_matches)
.on_click(cx.listener(|this, _, window, cx| { .on_click(cx.listener(|this, _, window, cx| {
this.next(window, cx); this.next(window, cx);
})), })),
) )
.child(div().w_5()) .child(
Label::new(self.matcher.label())
.when(!has_matches, |this| {
this.text_color(cx.theme().muted_foreground)
})
.text_left()
.min_w_16(),
)
.child(div().w_7())
.child( .child(
Button::new("close") Button::new("close")
.xsmall() .xsmall()
@ -488,13 +530,14 @@ impl Render for SearchPanel {
TextInput::new(&self.replace_input) TextInput::new(&self.replace_input)
.focus_bordered(false) .focus_bordered(false)
.small() .small()
.w_full() .w(self.input_width)
.shadow_none(), .shadow_none(),
) )
.child( .child(
Button::new("replace-one") Button::new("replace-one")
.small() .small()
.label(t!("Input.Replace")) .label(t!("Input.Replace"))
.disabled(!has_matches)
.on_click(cx.listener(|this, _, window, cx| { .on_click(cx.listener(|this, _, window, cx| {
this.replace_next(window, cx); this.replace_next(window, cx);
})), })),
@ -503,6 +546,7 @@ impl Render for SearchPanel {
Button::new("replace-all") Button::new("replace-all")
.small() .small()
.label(t!("Input.Replace All")) .label(t!("Input.Replace All"))
.disabled(!has_matches)
.on_click(cx.listener(|this, _, window, cx| { .on_click(cx.listener(|this, _, window, cx| {
this.replace_all(window, cx); this.replace_all(window, cx);
})), })),
@ -519,12 +563,12 @@ mod tests {
#[test] #[test]
fn test_search() { fn test_search() {
let mut search = SearchMatcher::new(); let mut matcher = SearchMatcher::new();
search.update(&Rope::from("Hello 世界 this is a Is test string.")); matcher.update(&Rope::from("Hello 世界 this is a Is test string."));
search.update_query("Is", true); matcher.update_query("Is", true);
assert_eq!(search.len(), 3); assert_eq!(matcher.len(), 3);
let mut matches = search.clone().into_iter(); let mut matches = matcher.clone().into_iter();
assert_eq!(matches.current_match_ix, 0); assert_eq!(matches.current_match_ix, 0);
assert_eq!(matches.next(), Some(18..20)); assert_eq!(matches.next(), Some(18..20));
assert_eq!(matches.next(), Some(23..25)); assert_eq!(matches.next(), Some(23..25));
@ -539,9 +583,49 @@ mod tests {
assert_eq!(matches.current_match_ix, 0); assert_eq!(matches.current_match_ix, 0);
assert_eq!(matches.next_back(), Some(23..25)); assert_eq!(matches.next_back(), Some(23..25));
search.update_query("IS", false); matcher.update_query("IS", false);
assert_eq!(search.len(), 0); assert_eq!(matcher.len(), 0);
assert_eq!(search.next(), None); assert_eq!(matcher.next(), None);
assert_eq!(search.next_back(), None); assert_eq!(matcher.next_back(), None);
}
#[test]
fn test_search_label() {
let mut matcher = SearchMatcher::new();
matcher.update(&Rope::from("Hello 世界 this is a Is test string."));
matcher.update_query("Is", true);
assert_eq!(matcher.label(), "1/3");
matcher.next();
assert_eq!(matcher.label(), "2/3");
matcher.next();
assert_eq!(matcher.label(), "3/3");
matcher.next();
assert_eq!(matcher.label(), "1/3");
matcher.update_query("IS", false);
assert_eq!(matcher.label(), "0/0");
}
#[test]
fn test_select_range_start() {
let mut matcher = SearchMatcher::new();
matcher.matched_ranges = Rc::new(vec![5..10, 15..20, 25..30]);
matcher.update_cursor_by_offset(0);
assert_eq!(matcher.current_match_ix, 0);
matcher.update_cursor_by_offset(5);
assert_eq!(matcher.current_match_ix, 0);
matcher.update_cursor_by_offset(12);
assert_eq!(matcher.current_match_ix, 1);
matcher.update_cursor_by_offset(16);
assert_eq!(matcher.current_match_ix, 1);
matcher.update_cursor_by_offset(30);
assert_eq!(matcher.current_match_ix, 2);
matcher.update_cursor_by_offset(31);
assert_eq!(matcher.current_match_ix, 2);
} }
} }