use aho_corasick::AhoCorasick; use rust_i18n::t; use std::{ops::Range, rc::Rc}; use gpui::{ actions, canvas, div, prelude::FluentBuilder as _, App, AppContext as _, Context, Empty, Entity, FocusHandle, Focusable, Half, InteractiveElement as _, IntoElement, KeyBinding, ParentElement as _, Pixels, Render, Styled, Subscription, Window, }; use ropey::Rope; use crate::{ actions::SelectUp, button::{Button, ButtonVariants}, h_flex, input::{Enter, Escape, IndentInline, Input, InputEvent, InputState, RopeExt as _, Search}, label::Label, v_flex, ActiveTheme, Disableable, IconName, Selectable, Sizable, }; const KEY_CONTEXT: &'static str = "SearchPanel"; actions!(input, [Tab]); pub(super) fn init(cx: &mut App) { cx.bind_keys(vec![KeyBinding::new( "shift-enter", SelectUp, Some(KEY_CONTEXT), )]); } #[derive(Debug, Clone)] pub struct SearchMatcher { text: Rope, pub query: Option, pub(super) matched_ranges: Rc>>, pub(super) current_match_ix: usize, /// Is in replacing mode, if true, the next update will not reset the current match index. replacing: bool, } impl SearchMatcher { pub fn new() -> Self { Self { text: "".into(), query: None, matched_ranges: Rc::new(Vec::new()), current_match_ix: 0, replacing: false, } } /// Update source text and re-match pub(crate) fn update(&mut self, text: &Rope) { if self.text.eq(text) { return; } self.text = text.clone(); self.update_matches(); } fn update_matches(&mut self) { let mut new_ranges = Vec::new(); if let Some(query) = &self.query { let text = self.text.to_string(); // FIXME: Use stream find let matches = query.stream_find_iter(text.as_bytes()); for query_match in matches.into_iter() { let query_match = query_match.expect("query match for select all action"); new_ranges.push(query_match.range()); } } self.matched_ranges = Rc::new(new_ranges); if !self.replacing { self.current_match_ix = 0; self.replacing = false; } } /// Update the search query and reset the current match index. pub fn update_query(&mut self, query: &str, case_insensitive: bool) { if query.len() > 0 { self.query = Some( AhoCorasick::builder() .ascii_case_insensitive(case_insensitive) .build(&[query.to_string()]) .expect("failed to build AhoCorasick query in SearchMatcher"), ); } else { self.query = None; } self.update_matches(); } /// Returns the number of matches found. #[allow(unused)] #[inline] fn len(&self) -> usize { self.matched_ranges.len() } 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 { type Item = Range; fn next(&mut self) -> Option { if self.matched_ranges.is_empty() { return None; } if self.current_match_ix < self.matched_ranges.len().saturating_sub(1) { self.current_match_ix += 1; } else { self.current_match_ix = 0; } self.matched_ranges.get(self.current_match_ix).cloned() } } impl DoubleEndedIterator for SearchMatcher { fn next_back(&mut self) -> Option { if self.matched_ranges.is_empty() { return None; } if self.current_match_ix == 0 { self.current_match_ix = self.matched_ranges.len(); } self.current_match_ix -= 1; let item = self.matched_ranges[self.current_match_ix].clone(); Some(item) } } pub(super) struct SearchPanel { editor: Entity, search_input: Entity, replace_input: Entity, case_insensitive: bool, replace_mode: bool, matcher: SearchMatcher, input_width: Pixels, open: bool, _subscriptions: Vec, } impl InputState { /// Update the search matcher when text changes. pub(super) fn update_search(&mut self, cx: &mut App) { let Some(search_panel) = self.search_panel.as_ref() else { return; }; let text = self.text.clone(); search_panel.update(cx, |this, _| { this.matcher.update(&text); }); } pub(super) fn on_action_search( &mut self, _: &Search, window: &mut Window, cx: &mut Context, ) { if !self.searchable { return; } let search_panel = match self.search_panel.as_ref() { Some(panel) => panel.clone(), None => SearchPanel::new(cx.entity(), window, cx), }; let text = self.text.clone(); let editor = cx.entity(); let selected_text = Rope::from(self.selected_text()); search_panel.update(cx, |this, cx| { this.editor = editor; this.matcher.update(&text); this.show(&selected_text, window, cx); }); self.search_panel = Some(search_panel); cx.notify(); } } impl SearchPanel { 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, _, ev: &InputEvent, cx| { // Handle search input changes match ev { InputEvent::Change => { this.update_search_query(cx); } _ => {} } }), ]; Self { editor, search_input, replace_input, case_insensitive: true, replace_mode: false, matcher: SearchMatcher::new(), open: true, input_width: Pixels::ZERO, _subscriptions, } }) } pub(super) fn show( &mut self, selected_text: &Rope, window: &mut Window, cx: &mut Context, ) { self.open = true; self.search_input.read(cx).focus_handle.focus(window); 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); }); } 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); 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.editor.read(cx).focus_handle.focus(window); cx.notify(); } fn on_action_prev(&mut self, _: &SelectUp, window: &mut Window, cx: &mut Context) { self.prev(window, cx); } fn on_action_next(&mut self, _: &Enter, window: &mut Window, cx: &mut Context) { self.next(window, cx); } fn on_action_escape(&mut self, _: &Escape, window: &mut Window, cx: &mut Context) { self.hide(window, cx); } fn on_action_tab(&mut self, _: &IndentInline, window: &mut Window, cx: &mut Context) { 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.editor.update(cx, |state, cx| { state.scroll_to(range.start, cx); }); } } fn next(&mut self, _: &mut Window, cx: &mut Context) { if let Some(range) = self.matcher.next() { self.editor.update(cx, |state, cx| { state.scroll_to(range.end, cx); }); } } pub(super) fn matcher(&self) -> Option<&SearchMatcher> { if !self.open { return None; } Some(&self.matcher) } fn replace_next(&mut self, window: &mut Window, cx: &mut Context) { let new_text = self.replace_input.read(cx).value(); self.matcher.replacing = true; if let Some(range) = self .matcher .matched_ranges .get(self.matcher.current_match_ix) .cloned() { let text_state = self.editor.clone(); let next_range = self.matcher.peek().unwrap_or(range.clone()); cx.spawn_in(window, async move |_, cx| { cx.update(|window, cx| { text_state.update(cx, |state, cx| { let range_utf16 = state.range_to_utf16(&range); state.scroll_to(next_range.end, cx); state.replace_text_in_range_silent( Some(range_utf16), new_text.as_str(), window, cx, ); }); }) }) .detach(); } } fn replace_all(&mut self, window: &mut Window, cx: &mut Context) { let new_text = self.replace_input.read(cx).value(); self.matcher.replacing = true; let ranges = self.matcher.matched_ranges.clone(); if ranges.is_empty() { return; } let editor = self.editor.clone(); cx.spawn_in(window, async move |_, cx| { cx.update(|window, 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() { rope.replace(range.clone(), new_text.as_str()); } state.replace_text_in_range_silent( Some(0..state.text.len()), &rope.to_string(), window, cx, ); state.scroll_to(0, cx); }); }) }) .detach(); } } impl Focusable for SearchPanel { fn focus_handle(&self, cx: &App) -> FocusHandle { self.search_input.read(cx).focus_handle.clone() } } impl Render for SearchPanel { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { if !self.open { return Empty.into_any_element(); } let has_matches = self.matcher.len() > 0; v_flex() .id("search-panel") .occlude() .track_focus(&self.focus_handle(cx)) .key_context(KEY_CONTEXT) .on_action(cx.listener(Self::on_action_prev)) .on_action(cx.listener(Self::on_action_next)) .on_action(cx.listener(Self::on_action_escape)) .on_action(cx.listener(Self::on_action_tab)) .font_family(".SystemUIFont") .items_center() .py_2() .px_3() .w_full() .gap_1() .bg(cx.theme().popover) .border_b_1() .rounded(cx.theme().radius.half()) .border_color(cx.theme().border) .child( h_flex() .w_full() .gap_2() .child( div() .flex_1() .gap_1() .child( Input::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 }) } }, |_, _, _, _| {}, ) .absolute() .size_full(), ), ) .child( Button::new("replace-mode") .xsmall() .ghost() .icon(IconName::Replace) .selected(self.replace_mode) .on_click(cx.listener(|this, _, window, cx| { this.replace_mode = !this.replace_mode; 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(); })), ) .child( Button::new("prev") .xsmall() .ghost() .icon(IconName::ChevronLeft) .disabled(!has_matches) .on_click(cx.listener(|this, _, window, cx| { this.prev(window, cx); })), ) .child( Button::new("next") .xsmall() .ghost() .icon(IconName::ChevronRight) .disabled(!has_matches) .on_click(cx.listener(|this, _, window, cx| { this.next(window, cx); })), ) .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() .ghost() .icon(IconName::Close) .on_click(cx.listener(|this, _, window, cx| { this.on_action_escape(&Escape, window, cx); })), ), ) .when(self.replace_mode, |this| { this.child( h_flex() .w_full() .gap_2() .child( Input::new(&self.replace_input) .focus_bordered(false) .small() .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); })), ) .child( 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); })), ), ) }) .into_any_element() } } #[cfg(test)] mod tests { use super::*; #[test] fn test_search() { let mut matcher = SearchMatcher::new(); matcher.update(&Rope::from("Hello 世界 this is a Is test string.")); matcher.update_query("Is", true); assert_eq!(matcher.len(), 3); let mut matches = matcher.clone(); assert_eq!(matches.current_match_ix, 0); assert_eq!(matches.next(), Some(18..20)); assert_eq!(matches.next(), Some(23..25)); assert_eq!(matches.current_match_ix, 2); assert_eq!(matches.next(), Some(15..17)); assert_eq!(matches.current_match_ix, 0); assert_eq!(matches.next_back(), Some(23..25)); assert_eq!(matches.current_match_ix, 2); assert_eq!(matches.next_back(), Some(18..20)); assert_eq!(matches.current_match_ix, 1); assert_eq!(matches.next_back(), Some(15..17)); assert_eq!(matches.current_match_ix, 0); assert_eq!(matches.next_back(), Some(23..25)); 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); } }