From 51efa766eaf73703121941c14759b9b25ce5abe3 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 18 Sep 2025 16:07:35 +0800 Subject: [PATCH] editor: Improve search to select first match in visible range. (#1258) --- Cargo.lock | 51 +++++++ crates/ui/Cargo.toml | 2 +- crates/ui/src/input/search.rs | 254 ++++++++++++++++++++++------------ 3 files changed, 221 insertions(+), 86 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 83562a52..8a871110 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2979,6 +2979,19 @@ dependencies = [ "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]] name = "glib" version = "0.18.5" @@ -3118,6 +3131,7 @@ dependencies = [ "as-raw-xcb-connection", "ashpd 0.11.0", "async-task", + "backtrace", "bindgen 0.71.1", "blade-graphics", "blade-macros", @@ -4284,6 +4298,18 @@ dependencies = [ "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]] name = "libloading" version = "0.8.8" @@ -4311,6 +4337,18 @@ dependencies = [ "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]] name = "linux-raw-sys" version = "0.3.8" @@ -9215,11 +9253,13 @@ dependencies = [ "dunce", "futures", "futures-lite 1.13.0", + "git2", "globset", "itertools 0.14.0", "libc", "log", "nix 0.29.0", + "rand 0.9.2", "regex", "rust-embed", "schemars", @@ -9232,10 +9272,21 @@ dependencies = [ "tempfile", "tendril", "unicase", + "util_macros", "walkdir", "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]] name = "uuid" version = "1.18.0" diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 4cb2888d..39642760 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -48,7 +48,7 @@ tree-sitter-languages = [ ] [dependencies] -gpui.workspace = true +gpui = { workspace = true, "features" = ["test-support"] } sum_tree.workspace = true gpui_macros.workspace = true rope.workspace = true diff --git a/crates/ui/src/input/search.rs b/crates/ui/src/input/search.rs index 8d4edafc..9f85d914 100644 --- a/crates/ui/src/input/search.rs +++ b/crates/ui/src/input/search.rs @@ -3,9 +3,9 @@ use rust_i18n::t; use std::{ops::Range, rc::Rc}; use gpui::{ - actions, div, prelude::FluentBuilder as _, App, AppContext as _, Context, Empty, Entity, - EntityInputHandler, FocusHandle, Focusable, Half, InteractiveElement as _, IntoElement, - KeyBinding, ParentElement as _, Render, Styled, Subscription, Window, + actions, canvas, div, prelude::FluentBuilder as _, App, AppContext as _, Context, Empty, + Entity, EntityInputHandler, FocusHandle, Focusable, Half, InteractiveElement as _, IntoElement, + KeyBinding, ParentElement as _, Pixels, Render, Styled, Subscription, Window, }; use rope::Rope; @@ -14,7 +14,8 @@ use crate::{ button::{Button, ButtonVariants}, h_flex, 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"; @@ -103,6 +104,23 @@ impl SearchMatcher { fn peek(&self) -> Option> { 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 { @@ -141,12 +159,13 @@ impl DoubleEndedIterator for SearchMatcher { } pub(super) struct SearchPanel { - text_state: Entity, + editor: Entity, search_input: Entity, replace_input: Entity, case_insensitive: bool, replace_mode: bool, matcher: SearchMatcher, + input_width: Pixels, open: bool, _subscriptions: Vec, @@ -181,10 +200,10 @@ impl InputState { }; let text = self.text.clone(); - let text_state = cx.entity(); + let editor = cx.entity(); let selected_text = self.selected_text(); search_panel.update(cx, |this, cx| { - this.text_state = text_state; + this.editor = editor; this.matcher.update(&text); this.show(&selected_text, window, cx); }); @@ -194,34 +213,33 @@ impl InputState { } impl SearchPanel { - pub fn new(text_state: Entity, window: &mut Window, cx: &mut App) -> Entity { + pub fn new(editor: Entity, window: &mut Window, cx: &mut App) -> Entity { let search_input = cx.new(|cx| InputState::new(window, cx)); let replace_input = cx.new(|cx| InputState::new(window, cx)); cx.new(|cx| { - let _subscriptions = vec![cx.subscribe( - &search_input, - |this: &mut Self, search_input, ev: &InputEvent, cx| { - // Handle search input changes - match ev { - InputEvent::Change => { - let value = search_input.read(cx).value(); - this.matcher - .update_query(value.as_str(), this.case_insensitive); + let _subscriptions = + vec![ + cx.subscribe(&search_input, |this: &mut Self, _, ev: &InputEvent, cx| { + // Handle search input changes + match ev { + InputEvent::Change => { + this.update_search_query(cx); + } + _ => {} } - _ => {} - } - }, - )]; + }), + ]; Self { - text_state, + editor, search_input, replace_input, case_insensitive: true, replace_mode: false, matcher: SearchMatcher::new(), open: true, + input_width: Pixels::ZERO, _subscriptions, } }) @@ -238,24 +256,35 @@ impl SearchPanel { self.search_input.update(cx, |this, cx| { if selected_text.len() > 0 { + // Set value will emit to update_search_query this.set_value(selected_text.to_string(), window, cx); } this.select_all(&super::SelectAll, window, cx); }); - self.update_search(cx); - cx.notify(); } - fn update_search(&mut self, cx: &mut Context) { + fn update_search_query(&mut self, cx: &mut Context) { 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 .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.open = false; - self.text_state.read(cx).focus_handle.focus(window); + self.editor.read(cx).focus_handle.focus(window); cx.notify(); } @@ -272,32 +301,12 @@ impl SearchPanel { } fn on_action_tab(&mut self, _: &IndentInline, window: &mut Window, cx: &mut Context) { - self.text_state.focus_handle(cx).focus(window); - } - - fn update_text_selection(&mut self, cx: &mut Context) { - 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(); - } + self.editor.focus_handle(cx).focus(window); } fn prev(&mut self, _: &mut Window, cx: &mut Context) { 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); }); } @@ -305,7 +314,7 @@ impl SearchPanel { fn next(&mut self, _: &mut Window, cx: &mut Context) { 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); }); } @@ -328,7 +337,7 @@ impl SearchPanel { .get(self.matcher.current_match_ix) .cloned() { - let text_state = self.text_state.clone(); + let text_state = self.editor.clone(); let next_range = self.matcher.peek().unwrap_or(range.clone()); cx.spawn_in(window, async move |_, cx| { @@ -357,10 +366,10 @@ impl SearchPanel { return; } - let text_state = self.text_state.clone(); + let editor = self.editor.clone(); cx.spawn_in(window, async move |_, 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. let mut rope = state.text.clone(); for range in ranges.iter().rev() { @@ -392,6 +401,8 @@ impl Render for SearchPanel { return Empty.into_any_element(); } + let has_matches = self.matcher.len() > 0; + v_flex() .id("search-panel") .occlude() @@ -416,27 +427,44 @@ impl Render for SearchPanel { .w_full() .gap_2() .child( - div().flex_1().gap_1().child( - TextInput::new(&self.search_input) - .focus_bordered(false) - .suffix( - Button::new("case-insensitive") - .selected(!self.case_insensitive) - .xsmall() - .compact() - .ghost() - .icon(IconName::CaseSensitive) - .on_click(cx.listener(|this, _, _, cx| { - this.case_insensitive = !this.case_insensitive; - this.update_search(cx); - cx.notify(); - })), + div() + .flex_1() + .gap_1() + .child( + TextInput::new(&self.search_input) + .focus_bordered(false) + .suffix( + Button::new("case-insensitive") + .selected(!self.case_insensitive) + .xsmall() + .compact() + .ghost() + .icon(IconName::CaseSensitive) + .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() - .w_full() - .cleanable() - .shadow_none(), - ), + .absolute() + .size_full(), + ), ) .child( Button::new("replace-mode") @@ -446,7 +474,11 @@ impl Render for SearchPanel { .selected(self.replace_mode) .on_click(cx.listener(|this, _, window, cx| { 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(); })), ) @@ -455,6 +487,7 @@ impl Render for SearchPanel { .xsmall() .ghost() .icon(IconName::ChevronLeft) + .disabled(!has_matches) .on_click(cx.listener(|this, _, window, cx| { this.prev(window, cx); })), @@ -464,11 +497,20 @@ impl Render for SearchPanel { .xsmall() .ghost() .icon(IconName::ChevronRight) + .disabled(!has_matches) .on_click(cx.listener(|this, _, 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( Button::new("close") .xsmall() @@ -488,13 +530,14 @@ impl Render for SearchPanel { TextInput::new(&self.replace_input) .focus_bordered(false) .small() - .w_full() + .w(self.input_width) .shadow_none(), ) .child( Button::new("replace-one") .small() .label(t!("Input.Replace")) + .disabled(!has_matches) .on_click(cx.listener(|this, _, window, cx| { this.replace_next(window, cx); })), @@ -503,6 +546,7 @@ impl Render for SearchPanel { Button::new("replace-all") .small() .label(t!("Input.Replace All")) + .disabled(!has_matches) .on_click(cx.listener(|this, _, window, cx| { this.replace_all(window, cx); })), @@ -519,12 +563,12 @@ mod tests { #[test] fn test_search() { - let mut search = SearchMatcher::new(); - search.update(&Rope::from("Hello 世界 this is a Is test string.")); - search.update_query("Is", true); + let mut matcher = SearchMatcher::new(); + matcher.update(&Rope::from("Hello 世界 this is a Is test string.")); + matcher.update_query("Is", true); - assert_eq!(search.len(), 3); - let mut matches = search.clone().into_iter(); + assert_eq!(matcher.len(), 3); + let mut matches = matcher.clone().into_iter(); assert_eq!(matches.current_match_ix, 0); assert_eq!(matches.next(), Some(18..20)); assert_eq!(matches.next(), Some(23..25)); @@ -539,9 +583,49 @@ mod tests { assert_eq!(matches.current_match_ix, 0); assert_eq!(matches.next_back(), Some(23..25)); - search.update_query("IS", false); - assert_eq!(search.len(), 0); - assert_eq!(search.next(), None); - assert_eq!(search.next_back(), None); + matcher.update_query("IS", false); + assert_eq!(matcher.len(), 0); + assert_eq!(matcher.next(), 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); } }