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}