1use crate::{
2 active_match_index, match_index_for_direction, query_suggestion_for_editor, Direction,
3 SearchOption, SelectNextMatch, SelectPrevMatch, ToggleSearchOption,
4};
5use collections::HashMap;
6use editor::{Anchor, Autoscroll, Editor, MultiBuffer, SelectAll};
7use gpui::{
8 actions, elements::*, platform::CursorStyle, AppContext, ElementBox, Entity, ModelContext,
9 ModelHandle, MutableAppContext, RenderContext, Subscription, Task, View, ViewContext,
10 ViewHandle, WeakModelHandle, WeakViewHandle,
11};
12use menu::Confirm;
13use project::{search::SearchQuery, Project};
14use settings::Settings;
15use smallvec::SmallVec;
16use std::{
17 any::{Any, TypeId},
18 ops::Range,
19 path::PathBuf,
20};
21use util::ResultExt as _;
22use workspace::{
23 Item, ItemHandle, ItemNavHistory, Pane, ToolbarItemLocation, ToolbarItemView, Workspace,
24};
25
26actions!(project_search, [Deploy, SearchInNew, ToggleFocus]);
27
28const MAX_TAB_TITLE_LEN: usize = 24;
29
30#[derive(Default)]
31struct ActiveSearches(HashMap<WeakModelHandle<Project>, WeakViewHandle<ProjectSearchView>>);
32
33pub fn init(cx: &mut MutableAppContext) {
34 cx.set_global(ActiveSearches::default());
35 cx.add_action(ProjectSearchView::deploy);
36 cx.add_action(ProjectSearchBar::search);
37 cx.add_action(ProjectSearchBar::search_in_new);
38 cx.add_action(ProjectSearchBar::toggle_search_option);
39 cx.add_action(ProjectSearchBar::select_next_match);
40 cx.add_action(ProjectSearchBar::select_prev_match);
41 cx.add_action(ProjectSearchBar::toggle_focus);
42 cx.capture_action(ProjectSearchBar::tab);
43}
44
45struct ProjectSearch {
46 project: ModelHandle<Project>,
47 excerpts: ModelHandle<MultiBuffer>,
48 pending_search: Option<Task<Option<()>>>,
49 match_ranges: Vec<Range<Anchor>>,
50 active_query: Option<SearchQuery>,
51}
52
53pub struct ProjectSearchView {
54 model: ModelHandle<ProjectSearch>,
55 query_editor: ViewHandle<Editor>,
56 results_editor: ViewHandle<Editor>,
57 case_sensitive: bool,
58 whole_word: bool,
59 regex: bool,
60 query_contains_error: bool,
61 active_match_index: Option<usize>,
62 results_editor_was_focused: bool,
63}
64
65pub struct ProjectSearchBar {
66 active_project_search: Option<ViewHandle<ProjectSearchView>>,
67 subscription: Option<Subscription>,
68}
69
70impl Entity for ProjectSearch {
71 type Event = ();
72}
73
74impl ProjectSearch {
75 fn new(project: ModelHandle<Project>, cx: &mut ModelContext<Self>) -> Self {
76 let replica_id = project.read(cx).replica_id();
77 Self {
78 project,
79 excerpts: cx.add_model(|_| MultiBuffer::new(replica_id)),
80 pending_search: Default::default(),
81 match_ranges: Default::default(),
82 active_query: None,
83 }
84 }
85
86 fn clone(&self, cx: &mut ModelContext<Self>) -> ModelHandle<Self> {
87 cx.add_model(|cx| Self {
88 project: self.project.clone(),
89 excerpts: self
90 .excerpts
91 .update(cx, |excerpts, cx| cx.add_model(|cx| excerpts.clone(cx))),
92 pending_search: Default::default(),
93 match_ranges: self.match_ranges.clone(),
94 active_query: self.active_query.clone(),
95 })
96 }
97
98 fn search(&mut self, query: SearchQuery, cx: &mut ModelContext<Self>) {
99 let search = self
100 .project
101 .update(cx, |project, cx| project.search(query.clone(), cx));
102 self.active_query = Some(query);
103 self.match_ranges.clear();
104 self.pending_search = Some(cx.spawn_weak(|this, mut cx| async move {
105 let matches = search.await.log_err()?;
106 if let Some(this) = this.upgrade(&cx) {
107 this.update(&mut cx, |this, cx| {
108 this.match_ranges.clear();
109 let mut matches = matches.into_iter().collect::<Vec<_>>();
110 matches
111 .sort_by_key(|(buffer, _)| buffer.read(cx).file().map(|file| file.path()));
112 this.excerpts.update(cx, |excerpts, cx| {
113 excerpts.clear(cx);
114 for (buffer, buffer_matches) in matches {
115 let ranges_to_highlight = excerpts.push_excerpts_with_context_lines(
116 buffer,
117 buffer_matches.clone(),
118 1,
119 cx,
120 );
121 this.match_ranges.extend(ranges_to_highlight);
122 }
123 });
124 this.pending_search.take();
125 cx.notify();
126 });
127 }
128 None
129 }));
130 cx.notify();
131 }
132}
133
134pub enum ViewEvent {
135 UpdateTab,
136 EditorEvent(editor::Event),
137}
138
139impl Entity for ProjectSearchView {
140 type Event = ViewEvent;
141}
142
143impl View for ProjectSearchView {
144 fn ui_name() -> &'static str {
145 "ProjectSearchView"
146 }
147
148 fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
149 let model = &self.model.read(cx);
150 if model.match_ranges.is_empty() {
151 let theme = &cx.global::<Settings>().theme;
152 let text = if self.query_editor.read(cx).text(cx).is_empty() {
153 ""
154 } else if model.pending_search.is_some() {
155 "Searching..."
156 } else {
157 "No results"
158 };
159 Label::new(text.to_string(), theme.search.results_status.clone())
160 .aligned()
161 .contained()
162 .with_background_color(theme.editor.background)
163 .flex(1., true)
164 .boxed()
165 } else {
166 ChildView::new(&self.results_editor).flex(1., true).boxed()
167 }
168 }
169
170 fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
171 let handle = cx.weak_handle();
172 cx.update_global(|state: &mut ActiveSearches, cx| {
173 state
174 .0
175 .insert(self.model.read(cx).project.downgrade(), handle)
176 });
177
178 if self.results_editor_was_focused && !self.model.read(cx).match_ranges.is_empty() {
179 self.focus_results_editor(cx);
180 } else {
181 cx.focus(&self.query_editor);
182 }
183 }
184}
185
186impl Item for ProjectSearchView {
187 fn act_as_type(
188 &self,
189 type_id: TypeId,
190 self_handle: &ViewHandle<Self>,
191 _: &gpui::AppContext,
192 ) -> Option<gpui::AnyViewHandle> {
193 if type_id == TypeId::of::<Self>() {
194 Some(self_handle.into())
195 } else if type_id == TypeId::of::<Editor>() {
196 Some((&self.results_editor).into())
197 } else {
198 None
199 }
200 }
201
202 fn deactivated(&mut self, cx: &mut ViewContext<Self>) {
203 self.results_editor
204 .update(cx, |editor, cx| editor.deactivated(cx));
205 }
206
207 fn tab_content(&self, tab_theme: &theme::Tab, cx: &gpui::AppContext) -> ElementBox {
208 let settings = cx.global::<Settings>();
209 let search_theme = &settings.theme.search;
210 Flex::row()
211 .with_child(
212 Svg::new("icons/magnifier.svg")
213 .with_color(tab_theme.label.text.color)
214 .constrained()
215 .with_width(search_theme.tab_icon_width)
216 .aligned()
217 .boxed(),
218 )
219 .with_children(self.model.read(cx).active_query.as_ref().map(|query| {
220 let query_text = if query.as_str().len() > MAX_TAB_TITLE_LEN {
221 query.as_str()[..MAX_TAB_TITLE_LEN].to_string() + "…"
222 } else {
223 query.as_str().to_string()
224 };
225
226 Label::new(query_text, tab_theme.label.clone())
227 .aligned()
228 .contained()
229 .with_margin_left(search_theme.tab_icon_spacing)
230 .boxed()
231 }))
232 .boxed()
233 }
234
235 fn project_path(&self, _: &gpui::AppContext) -> Option<project::ProjectPath> {
236 None
237 }
238
239 fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[project::ProjectEntryId; 3]> {
240 self.results_editor.project_entry_ids(cx)
241 }
242
243 fn is_singleton(&self, _: &AppContext) -> bool {
244 false
245 }
246
247 fn can_save(&self, _: &gpui::AppContext) -> bool {
248 true
249 }
250
251 fn is_dirty(&self, cx: &AppContext) -> bool {
252 self.results_editor.read(cx).is_dirty(cx)
253 }
254
255 fn has_conflict(&self, cx: &AppContext) -> bool {
256 self.results_editor.read(cx).has_conflict(cx)
257 }
258
259 fn save(
260 &mut self,
261 project: ModelHandle<Project>,
262 cx: &mut ViewContext<Self>,
263 ) -> Task<anyhow::Result<()>> {
264 self.results_editor
265 .update(cx, |editor, cx| editor.save(project, cx))
266 }
267
268 fn save_as(
269 &mut self,
270 _: ModelHandle<Project>,
271 _: PathBuf,
272 _: &mut ViewContext<Self>,
273 ) -> Task<anyhow::Result<()>> {
274 unreachable!("save_as should not have been called")
275 }
276
277 fn reload(
278 &mut self,
279 project: ModelHandle<Project>,
280 cx: &mut ViewContext<Self>,
281 ) -> Task<anyhow::Result<()>> {
282 self.results_editor
283 .update(cx, |editor, cx| editor.reload(project, cx))
284 }
285
286 fn clone_on_split(&self, cx: &mut ViewContext<Self>) -> Option<Self>
287 where
288 Self: Sized,
289 {
290 let model = self.model.update(cx, |model, cx| model.clone(cx));
291 Some(Self::new(model, cx))
292 }
293
294 fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut ViewContext<Self>) {
295 self.results_editor.update(cx, |editor, _| {
296 editor.set_nav_history(Some(nav_history));
297 });
298 }
299
300 fn navigate(&mut self, data: Box<dyn Any>, cx: &mut ViewContext<Self>) -> bool {
301 self.results_editor
302 .update(cx, |editor, cx| editor.navigate(data, cx))
303 }
304
305 fn should_activate_item_on_event(event: &Self::Event) -> bool {
306 if let ViewEvent::EditorEvent(editor_event) = event {
307 Editor::should_activate_item_on_event(editor_event)
308 } else {
309 false
310 }
311 }
312
313 fn should_update_tab_on_event(event: &ViewEvent) -> bool {
314 matches!(event, ViewEvent::UpdateTab)
315 }
316}
317
318impl ProjectSearchView {
319 fn new(model: ModelHandle<ProjectSearch>, cx: &mut ViewContext<Self>) -> Self {
320 let project;
321 let excerpts;
322 let mut query_text = String::new();
323 let mut regex = false;
324 let mut case_sensitive = false;
325 let mut whole_word = false;
326
327 {
328 let model = model.read(cx);
329 project = model.project.clone();
330 excerpts = model.excerpts.clone();
331 if let Some(active_query) = model.active_query.as_ref() {
332 query_text = active_query.as_str().to_string();
333 regex = active_query.is_regex();
334 case_sensitive = active_query.case_sensitive();
335 whole_word = active_query.whole_word();
336 }
337 }
338 cx.observe(&model, |this, _, cx| this.model_changed(true, cx))
339 .detach();
340
341 let query_editor = cx.add_view(|cx| {
342 let mut editor =
343 Editor::single_line(Some(|theme| theme.search.editor.input.clone()), cx);
344 editor.set_text(query_text, cx);
345 editor
346 });
347 // Subcribe to query_editor in order to reraise editor events for workspace item activation purposes
348 cx.subscribe(&query_editor, |_, _, event, cx| {
349 cx.emit(ViewEvent::EditorEvent(event.clone()))
350 })
351 .detach();
352 cx.observe_focus(&query_editor, |this, _, _| {
353 this.results_editor_was_focused = false;
354 })
355 .detach();
356
357 let results_editor = cx.add_view(|cx| {
358 let mut editor = Editor::for_multibuffer(excerpts, Some(project), cx);
359 editor.set_searchable(false);
360 editor
361 });
362 cx.observe(&results_editor, |_, _, cx| cx.emit(ViewEvent::UpdateTab))
363 .detach();
364 cx.observe_focus(&results_editor, |this, _, _| {
365 this.results_editor_was_focused = true;
366 })
367 .detach();
368 cx.subscribe(&results_editor, |this, _, event, cx| {
369 if matches!(event, editor::Event::SelectionsChanged { .. }) {
370 this.update_match_index(cx);
371 }
372 // Reraise editor events for workspace item activation purposes
373 cx.emit(ViewEvent::EditorEvent(event.clone()));
374 })
375 .detach();
376
377 let mut this = ProjectSearchView {
378 model,
379 query_editor,
380 results_editor,
381 case_sensitive,
382 whole_word,
383 regex,
384 query_contains_error: false,
385 active_match_index: None,
386 results_editor_was_focused: false,
387 };
388 this.model_changed(false, cx);
389 this
390 }
391
392 // Re-activate the most recently activated search or the most recent if it has been closed.
393 // If no search exists in the workspace, create a new one.
394 fn deploy(workspace: &mut Workspace, _: &Deploy, cx: &mut ViewContext<Workspace>) {
395 // Clean up entries for dropped projects
396 cx.update_global(|state: &mut ActiveSearches, cx| {
397 state.0.retain(|project, _| project.is_upgradable(cx))
398 });
399
400 let active_search = cx
401 .global::<ActiveSearches>()
402 .0
403 .get(&workspace.project().downgrade());
404
405 let existing = active_search
406 .and_then(|active_search| {
407 workspace
408 .items_of_type::<ProjectSearchView>(cx)
409 .find(|search| search == active_search)
410 })
411 .or_else(|| workspace.item_of_type::<ProjectSearchView>(cx));
412
413 let query = workspace.active_item(cx).and_then(|item| {
414 let editor = item.act_as::<Editor>(cx)?;
415 let query = query_suggestion_for_editor(&editor, cx);
416 if query.is_empty() {
417 None
418 } else {
419 Some(query)
420 }
421 });
422
423 let search = if let Some(existing) = existing {
424 workspace.activate_item(&existing, cx);
425 existing
426 } else {
427 let model = cx.add_model(|cx| ProjectSearch::new(workspace.project().clone(), cx));
428 let view = cx.add_view(|cx| ProjectSearchView::new(model, cx));
429 workspace.add_item(Box::new(view.clone()), cx);
430 view
431 };
432
433 search.update(cx, |search, cx| {
434 if let Some(query) = query {
435 search.set_query(&query, cx);
436 }
437 search.focus_query_editor(cx)
438 });
439 }
440
441 fn search(&mut self, cx: &mut ViewContext<Self>) {
442 if let Some(query) = self.build_search_query(cx) {
443 self.model.update(cx, |model, cx| model.search(query, cx));
444 }
445 }
446
447 fn build_search_query(&mut self, cx: &mut ViewContext<Self>) -> Option<SearchQuery> {
448 let text = self.query_editor.read(cx).text(cx);
449 if self.regex {
450 match SearchQuery::regex(text, self.whole_word, self.case_sensitive) {
451 Ok(query) => Some(query),
452 Err(_) => {
453 self.query_contains_error = true;
454 cx.notify();
455 None
456 }
457 }
458 } else {
459 Some(SearchQuery::text(
460 text,
461 self.whole_word,
462 self.case_sensitive,
463 ))
464 }
465 }
466
467 fn select_match(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
468 if let Some(index) = self.active_match_index {
469 let model = self.model.read(cx);
470 let results_editor = self.results_editor.read(cx);
471 let new_index = match_index_for_direction(
472 &model.match_ranges,
473 &results_editor.selections.newest_anchor().head(),
474 index,
475 direction,
476 &results_editor.buffer().read(cx).snapshot(cx),
477 );
478 let range_to_select = model.match_ranges[new_index].clone();
479 self.results_editor.update(cx, |editor, cx| {
480 editor.unfold_ranges([range_to_select.clone()], false, cx);
481 editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
482 s.select_ranges([range_to_select])
483 });
484 });
485 }
486 }
487
488 fn focus_query_editor(&self, cx: &mut ViewContext<Self>) {
489 self.query_editor.update(cx, |query_editor, cx| {
490 query_editor.select_all(&SelectAll, cx);
491 });
492 cx.focus(&self.query_editor);
493 }
494
495 fn set_query(&mut self, query: &str, cx: &mut ViewContext<Self>) {
496 self.query_editor
497 .update(cx, |query_editor, cx| query_editor.set_text(query, cx));
498 }
499
500 fn focus_results_editor(&self, cx: &mut ViewContext<Self>) {
501 self.query_editor.update(cx, |query_editor, cx| {
502 let cursor = query_editor.selections.newest_anchor().head();
503 query_editor.change_selections(None, cx, |s| s.select_ranges([cursor.clone()..cursor]));
504 });
505 cx.focus(&self.results_editor);
506 }
507
508 fn model_changed(&mut self, reset_selections: bool, cx: &mut ViewContext<Self>) {
509 let match_ranges = self.model.read(cx).match_ranges.clone();
510 if match_ranges.is_empty() {
511 self.active_match_index = None;
512 } else {
513 self.results_editor.update(cx, |editor, cx| {
514 if reset_selections {
515 editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
516 s.select_ranges(match_ranges.first().cloned())
517 });
518 }
519 editor.highlight_background::<Self>(
520 match_ranges,
521 |theme| theme.search.match_background,
522 cx,
523 );
524 });
525 if self.query_editor.is_focused(cx) {
526 self.focus_results_editor(cx);
527 }
528 }
529
530 cx.emit(ViewEvent::UpdateTab);
531 cx.notify();
532 }
533
534 fn update_match_index(&mut self, cx: &mut ViewContext<Self>) {
535 let results_editor = self.results_editor.read(cx);
536 let new_index = active_match_index(
537 &self.model.read(cx).match_ranges,
538 &results_editor.selections.newest_anchor().head(),
539 &results_editor.buffer().read(cx).snapshot(cx),
540 );
541 if self.active_match_index != new_index {
542 self.active_match_index = new_index;
543 cx.notify();
544 }
545 }
546
547 pub fn has_matches(&self) -> bool {
548 self.active_match_index.is_some()
549 }
550}
551
552impl ProjectSearchBar {
553 pub fn new() -> Self {
554 Self {
555 active_project_search: Default::default(),
556 subscription: Default::default(),
557 }
558 }
559
560 fn search(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
561 if let Some(search_view) = self.active_project_search.as_ref() {
562 search_view.update(cx, |search_view, cx| search_view.search(cx));
563 }
564 }
565
566 fn search_in_new(workspace: &mut Workspace, _: &SearchInNew, cx: &mut ViewContext<Workspace>) {
567 if let Some(search_view) = workspace
568 .active_item(cx)
569 .and_then(|item| item.downcast::<ProjectSearchView>())
570 {
571 let new_query = search_view.update(cx, |search_view, cx| {
572 let new_query = search_view.build_search_query(cx);
573 if new_query.is_some() {
574 if let Some(old_query) = search_view.model.read(cx).active_query.clone() {
575 search_view.query_editor.update(cx, |editor, cx| {
576 editor.set_text(old_query.as_str(), cx);
577 });
578 search_view.regex = old_query.is_regex();
579 search_view.whole_word = old_query.whole_word();
580 search_view.case_sensitive = old_query.case_sensitive();
581 }
582 }
583 new_query
584 });
585 if let Some(new_query) = new_query {
586 let model = cx.add_model(|cx| {
587 let mut model = ProjectSearch::new(workspace.project().clone(), cx);
588 model.search(new_query, cx);
589 model
590 });
591 workspace.add_item(
592 Box::new(cx.add_view(|cx| ProjectSearchView::new(model, cx))),
593 cx,
594 );
595 }
596 }
597 }
598
599 fn select_next_match(pane: &mut Pane, _: &SelectNextMatch, cx: &mut ViewContext<Pane>) {
600 if let Some(search_view) = pane
601 .active_item()
602 .and_then(|item| item.downcast::<ProjectSearchView>())
603 {
604 search_view.update(cx, |view, cx| view.select_match(Direction::Next, cx));
605 } else {
606 cx.propagate_action();
607 }
608 }
609
610 fn select_prev_match(pane: &mut Pane, _: &SelectPrevMatch, cx: &mut ViewContext<Pane>) {
611 if let Some(search_view) = pane
612 .active_item()
613 .and_then(|item| item.downcast::<ProjectSearchView>())
614 {
615 search_view.update(cx, |view, cx| view.select_match(Direction::Prev, cx));
616 } else {
617 cx.propagate_action();
618 }
619 }
620
621 fn toggle_focus(pane: &mut Pane, _: &ToggleFocus, cx: &mut ViewContext<Pane>) {
622 if let Some(search_view) = pane
623 .active_item()
624 .and_then(|item| item.downcast::<ProjectSearchView>())
625 {
626 search_view.update(cx, |search_view, cx| {
627 if search_view.query_editor.is_focused(cx) {
628 if !search_view.model.read(cx).match_ranges.is_empty() {
629 search_view.focus_results_editor(cx);
630 }
631 } else {
632 search_view.focus_query_editor(cx);
633 }
634 });
635 } else {
636 cx.propagate_action();
637 }
638 }
639
640 fn tab(&mut self, _: &editor::Tab, cx: &mut ViewContext<Self>) {
641 if let Some(search_view) = self.active_project_search.as_ref() {
642 search_view.update(cx, |search_view, cx| {
643 if search_view.query_editor.is_focused(cx) {
644 if !search_view.model.read(cx).match_ranges.is_empty() {
645 search_view.focus_results_editor(cx);
646 }
647 } else {
648 cx.propagate_action();
649 }
650 });
651 } else {
652 cx.propagate_action();
653 }
654 }
655
656 fn toggle_search_option(
657 &mut self,
658 ToggleSearchOption(option): &ToggleSearchOption,
659 cx: &mut ViewContext<Self>,
660 ) {
661 if let Some(search_view) = self.active_project_search.as_ref() {
662 search_view.update(cx, |search_view, cx| {
663 let value = match option {
664 SearchOption::WholeWord => &mut search_view.whole_word,
665 SearchOption::CaseSensitive => &mut search_view.case_sensitive,
666 SearchOption::Regex => &mut search_view.regex,
667 };
668 *value = !*value;
669 search_view.search(cx);
670 });
671 cx.notify();
672 }
673 }
674
675 fn render_nav_button(
676 &self,
677 icon: &str,
678 direction: Direction,
679 cx: &mut RenderContext<Self>,
680 ) -> ElementBox {
681 enum NavButton {}
682 MouseEventHandler::new::<NavButton, _, _>(direction as usize, cx, |state, cx| {
683 let style = &cx
684 .global::<Settings>()
685 .theme
686 .search
687 .option_button
688 .style_for(state, false);
689 Label::new(icon.to_string(), style.text.clone())
690 .contained()
691 .with_style(style.container)
692 .boxed()
693 })
694 .on_click(move |_, _, cx| match direction {
695 Direction::Prev => cx.dispatch_action(SelectPrevMatch),
696 Direction::Next => cx.dispatch_action(SelectNextMatch),
697 })
698 .with_cursor_style(CursorStyle::PointingHand)
699 .boxed()
700 }
701
702 fn render_option_button(
703 &self,
704 icon: &str,
705 option: SearchOption,
706 cx: &mut RenderContext<Self>,
707 ) -> ElementBox {
708 let is_active = self.is_option_enabled(option, cx);
709 MouseEventHandler::new::<ProjectSearchBar, _, _>(option as usize, cx, |state, cx| {
710 let style = &cx
711 .global::<Settings>()
712 .theme
713 .search
714 .option_button
715 .style_for(state, is_active);
716 Label::new(icon.to_string(), style.text.clone())
717 .contained()
718 .with_style(style.container)
719 .boxed()
720 })
721 .on_click(move |_, _, cx| cx.dispatch_action(ToggleSearchOption(option)))
722 .with_cursor_style(CursorStyle::PointingHand)
723 .boxed()
724 }
725
726 fn is_option_enabled(&self, option: SearchOption, cx: &AppContext) -> bool {
727 if let Some(search) = self.active_project_search.as_ref() {
728 let search = search.read(cx);
729 match option {
730 SearchOption::WholeWord => search.whole_word,
731 SearchOption::CaseSensitive => search.case_sensitive,
732 SearchOption::Regex => search.regex,
733 }
734 } else {
735 false
736 }
737 }
738}
739
740impl Entity for ProjectSearchBar {
741 type Event = ();
742}
743
744impl View for ProjectSearchBar {
745 fn ui_name() -> &'static str {
746 "ProjectSearchBar"
747 }
748
749 fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
750 if let Some(search) = self.active_project_search.as_ref() {
751 let search = search.read(cx);
752 let theme = cx.global::<Settings>().theme.clone();
753 let editor_container = if search.query_contains_error {
754 theme.search.invalid_editor
755 } else {
756 theme.search.editor.input.container
757 };
758 Flex::row()
759 .with_child(
760 Flex::row()
761 .with_child(
762 ChildView::new(&search.query_editor)
763 .aligned()
764 .left()
765 .flex(1., true)
766 .boxed(),
767 )
768 .with_children(search.active_match_index.map(|match_ix| {
769 Label::new(
770 format!(
771 "{}/{}",
772 match_ix + 1,
773 search.model.read(cx).match_ranges.len()
774 ),
775 theme.search.match_index.text.clone(),
776 )
777 .contained()
778 .with_style(theme.search.match_index.container)
779 .aligned()
780 .boxed()
781 }))
782 .contained()
783 .with_style(editor_container)
784 .aligned()
785 .constrained()
786 .with_min_width(theme.search.editor.min_width)
787 .with_max_width(theme.search.editor.max_width)
788 .flex(1., false)
789 .boxed(),
790 )
791 .with_child(
792 Flex::row()
793 .with_child(self.render_nav_button("<", Direction::Prev, cx))
794 .with_child(self.render_nav_button(">", Direction::Next, cx))
795 .aligned()
796 .boxed(),
797 )
798 .with_child(
799 Flex::row()
800 .with_child(self.render_option_button(
801 "Case",
802 SearchOption::CaseSensitive,
803 cx,
804 ))
805 .with_child(self.render_option_button("Word", SearchOption::WholeWord, cx))
806 .with_child(self.render_option_button("Regex", SearchOption::Regex, cx))
807 .contained()
808 .with_style(theme.search.option_button_group)
809 .aligned()
810 .boxed(),
811 )
812 .contained()
813 .with_style(theme.search.container)
814 .aligned()
815 .left()
816 .named("project search")
817 } else {
818 Empty::new().boxed()
819 }
820 }
821}
822
823impl ToolbarItemView for ProjectSearchBar {
824 fn set_active_pane_item(
825 &mut self,
826 active_pane_item: Option<&dyn workspace::ItemHandle>,
827 cx: &mut ViewContext<Self>,
828 ) -> ToolbarItemLocation {
829 cx.notify();
830 self.subscription = None;
831 self.active_project_search = None;
832 if let Some(search) = active_pane_item.and_then(|i| i.downcast::<ProjectSearchView>()) {
833 self.subscription = Some(cx.observe(&search, |_, _, cx| cx.notify()));
834 self.active_project_search = Some(search);
835 ToolbarItemLocation::PrimaryLeft {
836 flex: Some((1., false)),
837 }
838 } else {
839 ToolbarItemLocation::Hidden
840 }
841 }
842}
843
844#[cfg(test)]
845mod tests {
846 use super::*;
847 use editor::DisplayPoint;
848 use gpui::{color::Color, TestAppContext};
849 use project::FakeFs;
850 use serde_json::json;
851 use std::sync::Arc;
852
853 #[gpui::test]
854 async fn test_project_search(cx: &mut TestAppContext) {
855 let fonts = cx.font_cache();
856 let mut theme = gpui::fonts::with_font_cache(fonts.clone(), || theme::Theme::default());
857 theme.search.match_background = Color::red();
858 let settings = Settings::new("Courier", &fonts, Arc::new(theme)).unwrap();
859 cx.update(|cx| cx.set_global(settings));
860
861 let fs = FakeFs::new(cx.background());
862 fs.insert_tree(
863 "/dir",
864 json!({
865 "one.rs": "const ONE: usize = 1;",
866 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
867 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
868 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
869 }),
870 )
871 .await;
872 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
873 let search = cx.add_model(|cx| ProjectSearch::new(project, cx));
874 let search_view = cx.add_view(Default::default(), |cx| {
875 ProjectSearchView::new(search.clone(), cx)
876 });
877
878 search_view.update(cx, |search_view, cx| {
879 search_view
880 .query_editor
881 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
882 search_view.search(cx);
883 });
884 search_view.next_notification(&cx).await;
885 search_view.update(cx, |search_view, cx| {
886 assert_eq!(
887 search_view
888 .results_editor
889 .update(cx, |editor, cx| editor.display_text(cx)),
890 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;"
891 );
892 assert_eq!(
893 search_view
894 .results_editor
895 .update(cx, |editor, cx| editor.all_background_highlights(cx)),
896 &[
897 (
898 DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35),
899 Color::red()
900 ),
901 (
902 DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40),
903 Color::red()
904 ),
905 (
906 DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9),
907 Color::red()
908 )
909 ]
910 );
911 assert_eq!(search_view.active_match_index, Some(0));
912 assert_eq!(
913 search_view
914 .results_editor
915 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
916 [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
917 );
918
919 search_view.select_match(Direction::Next, cx);
920 });
921
922 search_view.update(cx, |search_view, cx| {
923 assert_eq!(search_view.active_match_index, Some(1));
924 assert_eq!(
925 search_view
926 .results_editor
927 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
928 [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
929 );
930 search_view.select_match(Direction::Next, cx);
931 });
932
933 search_view.update(cx, |search_view, cx| {
934 assert_eq!(search_view.active_match_index, Some(2));
935 assert_eq!(
936 search_view
937 .results_editor
938 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
939 [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
940 );
941 search_view.select_match(Direction::Next, cx);
942 });
943
944 search_view.update(cx, |search_view, cx| {
945 assert_eq!(search_view.active_match_index, Some(0));
946 assert_eq!(
947 search_view
948 .results_editor
949 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
950 [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
951 );
952 search_view.select_match(Direction::Prev, cx);
953 });
954
955 search_view.update(cx, |search_view, cx| {
956 assert_eq!(search_view.active_match_index, Some(2));
957 assert_eq!(
958 search_view
959 .results_editor
960 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
961 [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
962 );
963 search_view.select_match(Direction::Prev, cx);
964 });
965
966 search_view.update(cx, |search_view, cx| {
967 assert_eq!(search_view.active_match_index, Some(1));
968 assert_eq!(
969 search_view
970 .results_editor
971 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
972 [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
973 );
974 });
975 }
976}