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, MutableAppContext, RenderContext, Subscription, Task, View,
11 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/magnifier.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({
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(move |_, _, cx| cx.dispatch_any_action(option.to_toggle_action()))
774 .with_cursor_style(CursorStyle::PointingHand)
775 .with_tooltip::<Self, _>(
776 option as usize,
777 format!("Toggle {}", option.label()),
778 Some(option.to_toggle_action()),
779 tooltip_style,
780 cx,
781 )
782 .boxed()
783 }
784
785 fn is_option_enabled(&self, option: SearchOption, cx: &AppContext) -> bool {
786 if let Some(search) = self.active_project_search.as_ref() {
787 let search = search.read(cx);
788 match option {
789 SearchOption::WholeWord => search.whole_word,
790 SearchOption::CaseSensitive => search.case_sensitive,
791 SearchOption::Regex => search.regex,
792 }
793 } else {
794 false
795 }
796 }
797}
798
799impl Entity for ProjectSearchBar {
800 type Event = ();
801}
802
803impl View for ProjectSearchBar {
804 fn ui_name() -> &'static str {
805 "ProjectSearchBar"
806 }
807
808 fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
809 if let Some(search) = self.active_project_search.as_ref() {
810 let search = search.read(cx);
811 let theme = cx.global::<Settings>().theme.clone();
812 let editor_container = if search.query_contains_error {
813 theme.search.invalid_editor
814 } else {
815 theme.search.editor.input.container
816 };
817 Flex::row()
818 .with_child(
819 Flex::row()
820 .with_child(
821 ChildView::new(&search.query_editor)
822 .aligned()
823 .left()
824 .flex(1., true)
825 .boxed(),
826 )
827 .with_children(search.active_match_index.map(|match_ix| {
828 Label::new(
829 format!(
830 "{}/{}",
831 match_ix + 1,
832 search.model.read(cx).match_ranges.len()
833 ),
834 theme.search.match_index.text.clone(),
835 )
836 .contained()
837 .with_style(theme.search.match_index.container)
838 .aligned()
839 .boxed()
840 }))
841 .contained()
842 .with_style(editor_container)
843 .aligned()
844 .constrained()
845 .with_min_width(theme.search.editor.min_width)
846 .with_max_width(theme.search.editor.max_width)
847 .flex(1., false)
848 .boxed(),
849 )
850 .with_child(
851 Flex::row()
852 .with_child(self.render_nav_button("<", Direction::Prev, cx))
853 .with_child(self.render_nav_button(">", Direction::Next, cx))
854 .aligned()
855 .boxed(),
856 )
857 .with_child(
858 Flex::row()
859 .with_child(self.render_option_button(
860 "Case",
861 SearchOption::CaseSensitive,
862 cx,
863 ))
864 .with_child(self.render_option_button("Word", SearchOption::WholeWord, cx))
865 .with_child(self.render_option_button("Regex", SearchOption::Regex, cx))
866 .contained()
867 .with_style(theme.search.option_button_group)
868 .aligned()
869 .boxed(),
870 )
871 .contained()
872 .with_style(theme.search.container)
873 .aligned()
874 .left()
875 .named("project search")
876 } else {
877 Empty::new().boxed()
878 }
879 }
880}
881
882impl ToolbarItemView for ProjectSearchBar {
883 fn set_active_pane_item(
884 &mut self,
885 active_pane_item: Option<&dyn workspace::ItemHandle>,
886 cx: &mut ViewContext<Self>,
887 ) -> ToolbarItemLocation {
888 cx.notify();
889 self.subscription = None;
890 self.active_project_search = None;
891 if let Some(search) = active_pane_item.and_then(|i| i.downcast::<ProjectSearchView>()) {
892 self.subscription = Some(cx.observe(&search, |_, _, cx| cx.notify()));
893 self.active_project_search = Some(search);
894 ToolbarItemLocation::PrimaryLeft {
895 flex: Some((1., false)),
896 }
897 } else {
898 ToolbarItemLocation::Hidden
899 }
900 }
901}
902
903#[cfg(test)]
904mod tests {
905 use super::*;
906 use editor::DisplayPoint;
907 use gpui::{color::Color, TestAppContext};
908 use project::FakeFs;
909 use serde_json::json;
910 use std::sync::Arc;
911
912 #[gpui::test]
913 async fn test_project_search(cx: &mut TestAppContext) {
914 let fonts = cx.font_cache();
915 let mut theme = gpui::fonts::with_font_cache(fonts.clone(), || theme::Theme::default());
916 theme.search.match_background = Color::red();
917 cx.update(|cx| {
918 let mut settings = Settings::test(cx);
919 settings.theme = Arc::new(theme);
920 cx.set_global(settings)
921 });
922
923 let fs = FakeFs::new(cx.background());
924 fs.insert_tree(
925 "/dir",
926 json!({
927 "one.rs": "const ONE: usize = 1;",
928 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
929 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
930 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
931 }),
932 )
933 .await;
934 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
935 let search = cx.add_model(|cx| ProjectSearch::new(project, cx));
936 let search_view = cx.add_view(Default::default(), |cx| {
937 ProjectSearchView::new(search.clone(), cx)
938 });
939
940 search_view.update(cx, |search_view, cx| {
941 search_view
942 .query_editor
943 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
944 search_view.search(cx);
945 });
946 search_view.next_notification(&cx).await;
947 search_view.update(cx, |search_view, cx| {
948 assert_eq!(
949 search_view
950 .results_editor
951 .update(cx, |editor, cx| editor.display_text(cx)),
952 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;"
953 );
954 assert_eq!(
955 search_view
956 .results_editor
957 .update(cx, |editor, cx| editor.all_background_highlights(cx)),
958 &[
959 (
960 DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35),
961 Color::red()
962 ),
963 (
964 DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40),
965 Color::red()
966 ),
967 (
968 DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9),
969 Color::red()
970 )
971 ]
972 );
973 assert_eq!(search_view.active_match_index, Some(0));
974 assert_eq!(
975 search_view
976 .results_editor
977 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
978 [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
979 );
980
981 search_view.select_match(Direction::Next, cx);
982 });
983
984 search_view.update(cx, |search_view, cx| {
985 assert_eq!(search_view.active_match_index, Some(1));
986 assert_eq!(
987 search_view
988 .results_editor
989 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
990 [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
991 );
992 search_view.select_match(Direction::Next, cx);
993 });
994
995 search_view.update(cx, |search_view, cx| {
996 assert_eq!(search_view.active_match_index, Some(2));
997 assert_eq!(
998 search_view
999 .results_editor
1000 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1001 [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
1002 );
1003 search_view.select_match(Direction::Next, cx);
1004 });
1005
1006 search_view.update(cx, |search_view, cx| {
1007 assert_eq!(search_view.active_match_index, Some(0));
1008 assert_eq!(
1009 search_view
1010 .results_editor
1011 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1012 [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
1013 );
1014 search_view.select_match(Direction::Prev, cx);
1015 });
1016
1017 search_view.update(cx, |search_view, cx| {
1018 assert_eq!(search_view.active_match_index, Some(2));
1019 assert_eq!(
1020 search_view
1021 .results_editor
1022 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1023 [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
1024 );
1025 search_view.select_match(Direction::Prev, cx);
1026 });
1027
1028 search_view.update(cx, |search_view, cx| {
1029 assert_eq!(search_view.active_match_index, Some(1));
1030 assert_eq!(
1031 search_view
1032 .results_editor
1033 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1034 [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
1035 );
1036 });
1037 }
1038}