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