find.rs

  1use aho_corasick::AhoCorasickBuilder;
  2use anyhow::Result;
  3use collections::HashSet;
  4use editor::{char_kind, Anchor, Autoscroll, Editor, EditorSettings, MultiBufferSnapshot};
  5use gpui::{
  6    action, elements::*, keymap::Binding, Entity, MutableAppContext, RenderContext, Subscription,
  7    Task, View, ViewContext, ViewHandle, WeakViewHandle,
  8};
  9use postage::watch;
 10use regex::RegexBuilder;
 11use smol::future::yield_now;
 12use std::{
 13    cmp::{self, Ordering},
 14    ops::Range,
 15    sync::Arc,
 16};
 17use workspace::{ItemViewHandle, Settings, Toolbar, Workspace};
 18
 19action!(Deploy);
 20action!(Cancel);
 21action!(ToggleMode, SearchMode);
 22action!(GoToMatch, Direction);
 23
 24#[derive(Clone, Copy)]
 25pub enum Direction {
 26    Prev,
 27    Next,
 28}
 29
 30#[derive(Clone, Copy)]
 31pub enum SearchMode {
 32    WholeWord,
 33    CaseSensitive,
 34    Regex,
 35}
 36
 37pub fn init(cx: &mut MutableAppContext) {
 38    cx.add_bindings([
 39        Binding::new("cmd-f", Deploy, Some("Editor && mode == full")),
 40        Binding::new("escape", Cancel, Some("FindBar")),
 41    ]);
 42    cx.add_action(FindBar::deploy);
 43    cx.add_action(FindBar::cancel);
 44    cx.add_action(FindBar::toggle_mode);
 45    cx.add_action(FindBar::go_to_match);
 46}
 47
 48struct FindBar {
 49    settings: watch::Receiver<Settings>,
 50    query_editor: ViewHandle<Editor>,
 51    active_editor: Option<ViewHandle<Editor>>,
 52    active_match_index: Option<usize>,
 53    active_editor_subscription: Option<Subscription>,
 54    highlighted_editors: HashSet<WeakViewHandle<Editor>>,
 55    pending_search: Option<Task<()>>,
 56    case_sensitive_mode: bool,
 57    whole_word_mode: bool,
 58    regex_mode: bool,
 59    query_contains_error: bool,
 60}
 61
 62impl Entity for FindBar {
 63    type Event = ();
 64}
 65
 66impl View for FindBar {
 67    fn ui_name() -> &'static str {
 68        "FindBar"
 69    }
 70
 71    fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
 72        cx.focus(&self.query_editor);
 73    }
 74
 75    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
 76        let theme = &self.settings.borrow().theme.find;
 77        let editor_container = if self.query_contains_error {
 78            theme.invalid_editor
 79        } else {
 80            theme.editor.input.container
 81        };
 82        Flex::row()
 83            .with_child(
 84                ChildView::new(&self.query_editor)
 85                    .contained()
 86                    .with_style(editor_container)
 87                    .constrained()
 88                    .with_max_width(theme.editor.max_width)
 89                    .boxed(),
 90            )
 91            .with_child(
 92                Flex::row()
 93                    .with_child(self.render_mode_button("Aa", SearchMode::CaseSensitive, theme, cx))
 94                    .with_child(self.render_mode_button("|ab|", SearchMode::WholeWord, theme, cx))
 95                    .with_child(self.render_mode_button(".*", SearchMode::Regex, theme, cx))
 96                    .contained()
 97                    .with_style(theme.mode_button_group)
 98                    .boxed(),
 99            )
100            .with_child(
101                Flex::row()
102                    .with_child(self.render_nav_button("<", Direction::Prev, theme, cx))
103                    .with_child(self.render_nav_button(">", Direction::Next, theme, cx))
104                    .boxed(),
105            )
106            .with_children(self.active_editor.as_ref().and_then(|editor| {
107                let (_, highlighted_ranges) =
108                    editor.read(cx).highlighted_ranges_for_type::<Self>()?;
109                let match_ix = cmp::min(self.active_match_index? + 1, highlighted_ranges.len());
110                Some(
111                    Label::new(
112                        format!("{} of {}", match_ix, highlighted_ranges.len()),
113                        theme.match_index.text.clone(),
114                    )
115                    .contained()
116                    .with_style(theme.match_index.container)
117                    .boxed(),
118                )
119            }))
120            .contained()
121            .with_style(theme.container)
122            .boxed()
123    }
124}
125
126impl Toolbar for FindBar {
127    fn active_item_changed(
128        &mut self,
129        item: Option<Box<dyn ItemViewHandle>>,
130        cx: &mut ViewContext<Self>,
131    ) -> bool {
132        self.active_editor_subscription.take();
133        self.active_editor.take();
134        self.pending_search.take();
135
136        if let Some(editor) = item.and_then(|item| item.act_as::<Editor>(cx)) {
137            self.active_editor_subscription =
138                Some(cx.subscribe(&editor, Self::on_active_editor_event));
139            self.active_editor = Some(editor);
140            self.update_matches(cx);
141            true
142        } else {
143            false
144        }
145    }
146}
147
148impl FindBar {
149    fn new(settings: watch::Receiver<Settings>, cx: &mut ViewContext<Self>) -> Self {
150        let query_editor = cx.add_view(|cx| {
151            Editor::auto_height(
152                2,
153                {
154                    let settings = settings.clone();
155                    Arc::new(move |_| {
156                        let settings = settings.borrow();
157                        EditorSettings {
158                            style: settings.theme.find.editor.input.as_editor(),
159                            tab_size: settings.tab_size,
160                            soft_wrap: editor::SoftWrap::None,
161                        }
162                    })
163                },
164                cx,
165            )
166        });
167        cx.subscribe(&query_editor, Self::on_query_editor_event)
168            .detach();
169
170        Self {
171            query_editor,
172            active_editor: None,
173            active_editor_subscription: None,
174            active_match_index: None,
175            highlighted_editors: Default::default(),
176            case_sensitive_mode: false,
177            whole_word_mode: false,
178            regex_mode: false,
179            settings,
180            pending_search: None,
181            query_contains_error: false,
182        }
183    }
184
185    #[cfg(test)]
186    fn set_query(&mut self, query: &str, cx: &mut ViewContext<Self>) {
187        self.query_editor.update(cx, |query_editor, cx| {
188            query_editor.buffer().update(cx, |query_buffer, cx| {
189                let len = query_buffer.read(cx).len();
190                query_buffer.edit([0..len], query, cx);
191            });
192        });
193    }
194
195    fn render_mode_button(
196        &self,
197        icon: &str,
198        mode: SearchMode,
199        theme: &theme::Find,
200        cx: &mut RenderContext<Self>,
201    ) -> ElementBox {
202        let is_active = self.is_mode_enabled(mode);
203        MouseEventHandler::new::<Self, _, _, _>((cx.view_id(), mode as usize), cx, |state, _| {
204            let style = match (is_active, state.hovered) {
205                (false, false) => &theme.mode_button,
206                (false, true) => &theme.hovered_mode_button,
207                (true, false) => &theme.active_mode_button,
208                (true, true) => &theme.active_hovered_mode_button,
209            };
210            Label::new(icon.to_string(), style.text.clone())
211                .contained()
212                .with_style(style.container)
213                .boxed()
214        })
215        .on_click(move |cx| cx.dispatch_action(ToggleMode(mode)))
216        .boxed()
217    }
218
219    fn render_nav_button(
220        &self,
221        icon: &str,
222        direction: Direction,
223        theme: &theme::Find,
224        cx: &mut RenderContext<Self>,
225    ) -> ElementBox {
226        MouseEventHandler::new::<Self, _, _, _>(
227            (cx.view_id(), 10 + direction as usize),
228            cx,
229            |state, _| {
230                let style = if state.hovered {
231                    &theme.hovered_mode_button
232                } else {
233                    &theme.mode_button
234                };
235                Label::new(icon.to_string(), style.text.clone())
236                    .contained()
237                    .with_style(style.container)
238                    .boxed()
239            },
240        )
241        .on_click(move |cx| cx.dispatch_action(GoToMatch(direction)))
242        .boxed()
243    }
244
245    fn deploy(workspace: &mut Workspace, _: &Deploy, cx: &mut ViewContext<Workspace>) {
246        let settings = workspace.settings();
247        workspace.active_pane().update(cx, |pane, cx| {
248            pane.show_toolbar(cx, |cx| FindBar::new(settings, cx));
249            if let Some(toolbar) = pane.active_toolbar() {
250                cx.focus(toolbar);
251            }
252        });
253    }
254
255    fn cancel(workspace: &mut Workspace, _: &Cancel, cx: &mut ViewContext<Workspace>) {
256        workspace
257            .active_pane()
258            .update(cx, |pane, cx| pane.hide_toolbar(cx));
259    }
260
261    fn is_mode_enabled(&self, mode: SearchMode) -> bool {
262        match mode {
263            SearchMode::WholeWord => self.whole_word_mode,
264            SearchMode::CaseSensitive => self.case_sensitive_mode,
265            SearchMode::Regex => self.regex_mode,
266        }
267    }
268
269    fn toggle_mode(&mut self, ToggleMode(mode): &ToggleMode, cx: &mut ViewContext<Self>) {
270        let value = match mode {
271            SearchMode::WholeWord => &mut self.whole_word_mode,
272            SearchMode::CaseSensitive => &mut self.case_sensitive_mode,
273            SearchMode::Regex => &mut self.regex_mode,
274        };
275        *value = !*value;
276        self.update_matches(cx);
277        cx.notify();
278    }
279
280    fn go_to_match(&mut self, GoToMatch(direction): &GoToMatch, cx: &mut ViewContext<Self>) {
281        if let Some(mut index) = self.active_match_index {
282            if let Some(editor) = self.active_editor.as_ref() {
283                editor.update(cx, |editor, cx| {
284                    if let Some((_, ranges)) = editor.highlighted_ranges_for_type::<Self>() {
285                        match direction {
286                            Direction::Prev => {
287                                if index == 0 {
288                                    index = ranges.len() - 1;
289                                } else {
290                                    index -= 1;
291                                }
292                            }
293                            Direction::Next => {
294                                if index == ranges.len() - 1 {
295                                    index = 0;
296                                } else {
297                                    index += 1;
298                                }
299                            }
300                        }
301
302                        let range_to_select = ranges[index].clone();
303                        editor.select_ranges([range_to_select], Some(Autoscroll::Fit), cx);
304                    }
305                });
306            }
307        }
308    }
309
310    fn on_query_editor_event(
311        &mut self,
312        _: ViewHandle<Editor>,
313        event: &editor::Event,
314        cx: &mut ViewContext<Self>,
315    ) {
316        match event {
317            editor::Event::Edited => {
318                for editor in self.highlighted_editors.drain() {
319                    if let Some(editor) = editor.upgrade(cx) {
320                        if Some(&editor) != self.active_editor.as_ref() {
321                            editor.update(cx, |editor, cx| {
322                                editor.clear_highlighted_ranges::<Self>(cx)
323                            });
324                        }
325                    }
326                }
327                self.query_contains_error = false;
328                self.update_matches(cx);
329                cx.notify();
330            }
331            _ => {}
332        }
333    }
334
335    fn on_active_editor_event(
336        &mut self,
337        _: ViewHandle<Editor>,
338        event: &editor::Event,
339        cx: &mut ViewContext<Self>,
340    ) {
341        match event {
342            editor::Event::Edited => self.update_matches(cx),
343            editor::Event::SelectionsChanged => self.update_match_index(cx),
344            _ => {}
345        }
346    }
347
348    fn update_matches(&mut self, cx: &mut ViewContext<Self>) {
349        let query = self.query_editor.read(cx).text(cx);
350        self.pending_search.take();
351        if let Some(editor) = self.active_editor.as_ref() {
352            if query.is_empty() {
353                self.active_match_index.take();
354                editor.update(cx, |editor, cx| editor.clear_highlighted_ranges::<Self>(cx));
355            } else {
356                let buffer = editor.read(cx).buffer().read(cx).snapshot(cx);
357                let case_sensitive = self.case_sensitive_mode;
358                let whole_word = self.whole_word_mode;
359                let ranges = if self.regex_mode {
360                    cx.background()
361                        .spawn(regex_search(buffer, query, case_sensitive, whole_word))
362                } else {
363                    cx.background().spawn(async move {
364                        Ok(search(buffer, query, case_sensitive, whole_word).await)
365                    })
366                };
367
368                let editor = editor.downgrade();
369                self.pending_search = Some(cx.spawn(|this, mut cx| async move {
370                    match ranges.await {
371                        Ok(ranges) => {
372                            if let Some(editor) = cx.read(|cx| editor.upgrade(cx)) {
373                                this.update(&mut cx, |this, cx| {
374                                    this.highlighted_editors.insert(editor.downgrade());
375                                    editor.update(cx, |editor, cx| {
376                                        let theme = &this.settings.borrow().theme.find;
377                                        editor.highlight_ranges::<Self>(
378                                            ranges,
379                                            theme.match_background,
380                                            cx,
381                                        )
382                                    });
383                                    this.update_match_index(cx);
384                                });
385                            }
386                        }
387                        Err(_) => {
388                            this.update(&mut cx, |this, cx| {
389                                this.query_contains_error = true;
390                                cx.notify();
391                            });
392                        }
393                    }
394                }));
395            }
396        }
397    }
398
399    fn update_match_index(&mut self, cx: &mut ViewContext<Self>) {
400        self.active_match_index = self.active_match_index(cx);
401        cx.notify();
402    }
403
404    fn active_match_index(&mut self, cx: &mut ViewContext<Self>) -> Option<usize> {
405        let editor = self.active_editor.as_ref()?;
406        let editor = editor.read(cx);
407        let position = editor.newest_anchor_selection()?.head();
408        let ranges = editor.highlighted_ranges_for_type::<Self>()?.1;
409        let buffer = editor.buffer().read(cx).read(cx);
410        match ranges.binary_search_by(|probe| {
411            if probe.end.cmp(&position, &*buffer).unwrap().is_lt() {
412                Ordering::Less
413            } else if probe.start.cmp(&position, &*buffer).unwrap().is_gt() {
414                Ordering::Greater
415            } else {
416                Ordering::Equal
417            }
418        }) {
419            Ok(i) | Err(i) => Some(i),
420        }
421    }
422}
423
424const YIELD_INTERVAL: usize = 20000;
425
426async fn search(
427    buffer: MultiBufferSnapshot,
428    query: String,
429    case_sensitive: bool,
430    whole_word: bool,
431) -> Vec<Range<Anchor>> {
432    let mut ranges = Vec::new();
433
434    let search = AhoCorasickBuilder::new()
435        .auto_configure(&[&query])
436        .ascii_case_insensitive(!case_sensitive)
437        .build(&[&query]);
438    for (ix, mat) in search
439        .stream_find_iter(buffer.bytes_in_range(0..buffer.len()))
440        .enumerate()
441    {
442        if (ix + 1) % YIELD_INTERVAL == 0 {
443            yield_now().await;
444        }
445
446        let mat = mat.unwrap();
447
448        if whole_word {
449            let prev_kind = buffer.reversed_chars_at(mat.start()).next().map(char_kind);
450            let start_kind = char_kind(buffer.chars_at(mat.start()).next().unwrap());
451            let end_kind = char_kind(buffer.reversed_chars_at(mat.end()).next().unwrap());
452            let next_kind = buffer.chars_at(mat.end()).next().map(char_kind);
453            if Some(start_kind) == prev_kind || Some(end_kind) == next_kind {
454                continue;
455            }
456        }
457
458        ranges.push(buffer.anchor_after(mat.start())..buffer.anchor_before(mat.end()));
459    }
460
461    ranges
462}
463
464async fn regex_search(
465    buffer: MultiBufferSnapshot,
466    mut query: String,
467    case_sensitive: bool,
468    whole_word: bool,
469) -> Result<Vec<Range<Anchor>>> {
470    if whole_word {
471        let mut word_query = String::new();
472        word_query.push_str("\\b");
473        word_query.push_str(&query);
474        word_query.push_str("\\b");
475        query = word_query;
476    }
477
478    let mut ranges = Vec::new();
479
480    if query.contains("\n") || query.contains("\\n") {
481        let regex = RegexBuilder::new(&query)
482            .case_insensitive(!case_sensitive)
483            .multi_line(true)
484            .build()?;
485        for (ix, mat) in regex.find_iter(&buffer.text()).enumerate() {
486            if (ix + 1) % YIELD_INTERVAL == 0 {
487                yield_now().await;
488            }
489
490            ranges.push(buffer.anchor_after(mat.start())..buffer.anchor_before(mat.end()));
491        }
492    } else {
493        let regex = RegexBuilder::new(&query)
494            .case_insensitive(!case_sensitive)
495            .build()?;
496
497        let mut line = String::new();
498        let mut line_offset = 0;
499        for (chunk_ix, chunk) in buffer
500            .chunks(0..buffer.len(), None)
501            .map(|c| c.text)
502            .chain(["\n"])
503            .enumerate()
504        {
505            if (chunk_ix + 1) % YIELD_INTERVAL == 0 {
506                yield_now().await;
507            }
508
509            for (newline_ix, text) in chunk.split('\n').enumerate() {
510                if newline_ix > 0 {
511                    for mat in regex.find_iter(&line) {
512                        let start = line_offset + mat.start();
513                        let end = line_offset + mat.end();
514                        ranges.push(buffer.anchor_after(start)..buffer.anchor_before(end));
515                    }
516
517                    line_offset += line.len() + 1;
518                    line.clear();
519                }
520                line.push_str(text);
521            }
522        }
523    }
524
525    Ok(ranges)
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531    use editor::{DisplayPoint, Editor, EditorSettings, MultiBuffer};
532    use gpui::{color::Color, TestAppContext};
533    use std::sync::Arc;
534    use unindent::Unindent as _;
535
536    #[gpui::test]
537    async fn test_find_simple(mut cx: TestAppContext) {
538        let fonts = cx.font_cache();
539        let mut theme = gpui::fonts::with_font_cache(fonts.clone(), || theme::Theme::default());
540        theme.find.match_background = Color::red();
541        let settings = Settings::new("Courier", &fonts, Arc::new(theme)).unwrap();
542
543        let buffer = cx.update(|cx| {
544            MultiBuffer::build_simple(
545                &r#"
546                A regular expression (shortened as regex or regexp;[1] also referred to as
547                rational expression[2][3]) is a sequence of characters that specifies a search
548                pattern in text. Usually such patterns are used by string-searching algorithms
549                for "find" or "find and replace" operations on strings, or for input validation.
550                "#
551                .unindent(),
552                cx,
553            )
554        });
555        let editor = cx.add_view(Default::default(), |cx| {
556            Editor::new(buffer.clone(), Arc::new(EditorSettings::test), cx)
557        });
558
559        let find_bar = cx.add_view(Default::default(), |cx| {
560            let mut find_bar = FindBar::new(watch::channel_with(settings).1, cx);
561            find_bar.active_item_changed(Some(Box::new(editor.clone())), cx);
562            find_bar
563        });
564
565        // Search for a string that appears with different casing.
566        // By default, search is case-insensitive.
567        find_bar.update(&mut cx, |find_bar, cx| {
568            find_bar.set_query("us", cx);
569        });
570        editor.next_notification(&cx).await;
571        editor.update(&mut cx, |editor, cx| {
572            assert_eq!(
573                editor.all_highlighted_ranges(cx),
574                &[
575                    (
576                        DisplayPoint::new(2, 17)..DisplayPoint::new(2, 19),
577                        Color::red(),
578                    ),
579                    (
580                        DisplayPoint::new(2, 43)..DisplayPoint::new(2, 45),
581                        Color::red(),
582                    ),
583                ]
584            );
585        });
586
587        // Switch to a case sensitive search.
588        find_bar.update(&mut cx, |find_bar, cx| {
589            find_bar.toggle_mode(&ToggleMode(SearchMode::CaseSensitive), cx);
590        });
591        editor.next_notification(&cx).await;
592        editor.update(&mut cx, |editor, cx| {
593            assert_eq!(
594                editor.all_highlighted_ranges(cx),
595                &[(
596                    DisplayPoint::new(2, 43)..DisplayPoint::new(2, 45),
597                    Color::red(),
598                )]
599            );
600        });
601
602        // Search for a string that appears both as a whole word and
603        // within other words. By default, all results are found.
604        find_bar.update(&mut cx, |find_bar, cx| {
605            find_bar.set_query("or", cx);
606        });
607        editor.next_notification(&cx).await;
608        editor.update(&mut cx, |editor, cx| {
609            assert_eq!(
610                editor.all_highlighted_ranges(cx),
611                &[
612                    (
613                        DisplayPoint::new(0, 24)..DisplayPoint::new(0, 26),
614                        Color::red(),
615                    ),
616                    (
617                        DisplayPoint::new(0, 41)..DisplayPoint::new(0, 43),
618                        Color::red(),
619                    ),
620                    (
621                        DisplayPoint::new(2, 71)..DisplayPoint::new(2, 73),
622                        Color::red(),
623                    ),
624                    (
625                        DisplayPoint::new(3, 1)..DisplayPoint::new(3, 3),
626                        Color::red(),
627                    ),
628                    (
629                        DisplayPoint::new(3, 11)..DisplayPoint::new(3, 13),
630                        Color::red(),
631                    ),
632                    (
633                        DisplayPoint::new(3, 56)..DisplayPoint::new(3, 58),
634                        Color::red(),
635                    ),
636                    (
637                        DisplayPoint::new(3, 60)..DisplayPoint::new(3, 62),
638                        Color::red(),
639                    ),
640                ]
641            );
642        });
643
644        // Switch to a whole word search.
645        find_bar.update(&mut cx, |find_bar, cx| {
646            find_bar.toggle_mode(&ToggleMode(SearchMode::WholeWord), cx);
647        });
648        editor.next_notification(&cx).await;
649        editor.update(&mut cx, |editor, cx| {
650            assert_eq!(
651                editor.all_highlighted_ranges(cx),
652                &[
653                    (
654                        DisplayPoint::new(0, 41)..DisplayPoint::new(0, 43),
655                        Color::red(),
656                    ),
657                    (
658                        DisplayPoint::new(3, 11)..DisplayPoint::new(3, 13),
659                        Color::red(),
660                    ),
661                    (
662                        DisplayPoint::new(3, 56)..DisplayPoint::new(3, 58),
663                        Color::red(),
664                    ),
665                ]
666            );
667        });
668
669        find_bar.update(&mut cx, |find_bar, cx| {
670            find_bar.go_to_match(&GoToMatch(Direction::Next), cx);
671        });
672    }
673}