1use crate::{
2 history::SearchHistory,
3 mode::SearchMode,
4 search_bar::{render_nav_button, render_option_button_icon, render_search_mode_button},
5 CycleMode, NextHistoryQuery, PreviousHistoryQuery, SearchOptions, SelectNextMatch,
6 SelectPrevMatch, ToggleCaseSensitive, ToggleWholeWord,
7};
8use anyhow::Context;
9use collections::HashMap;
10use editor::{
11 items::active_match_index, scroll::autoscroll::Autoscroll, Anchor, Editor, MultiBuffer,
12 SelectAll, MAX_TAB_TITLE_LEN,
13};
14use futures::StreamExt;
15
16use gpui::{
17 actions, elements::*, platform::MouseButton, Action, AnyElement, AnyViewHandle, AppContext,
18 Entity, ModelContext, ModelHandle, Subscription, Task, View, ViewContext, ViewHandle,
19 WeakModelHandle, WeakViewHandle,
20};
21
22use menu::Confirm;
23use project::{
24 search::{PathMatcher, SearchQuery},
25 Entry, Project,
26};
27use smallvec::SmallVec;
28use std::{
29 any::{Any, TypeId},
30 borrow::Cow,
31 collections::HashSet,
32 mem,
33 ops::{Not, Range},
34 path::PathBuf,
35 sync::Arc,
36};
37use util::ResultExt as _;
38use workspace::{
39 item::{BreadcrumbText, Item, ItemEvent, ItemHandle},
40 searchable::{Direction, SearchableItem, SearchableItemHandle},
41 ItemNavHistory, Pane, ToolbarItemLocation, ToolbarItemView, Workspace, WorkspaceId,
42};
43
44actions!(
45 project_search,
46 [SearchInNew, ToggleFocus, NextField, ToggleFilters,]
47);
48
49#[derive(Default)]
50struct ActiveSearches(HashMap<WeakModelHandle<Project>, WeakViewHandle<ProjectSearchView>>);
51
52pub fn init(cx: &mut AppContext) {
53 cx.set_global(ActiveSearches::default());
54 cx.add_action(ProjectSearchView::deploy);
55 cx.add_action(ProjectSearchView::move_focus_to_results);
56 cx.add_action(ProjectSearchBar::search);
57 cx.add_action(ProjectSearchBar::search_in_new);
58 cx.add_action(ProjectSearchBar::select_next_match);
59 cx.add_action(ProjectSearchBar::select_prev_match);
60 cx.add_action(ProjectSearchBar::cycle_mode);
61 cx.add_action(ProjectSearchBar::next_history_query);
62 cx.add_action(ProjectSearchBar::previous_history_query);
63 // cx.add_action(ProjectSearchBar::activate_regex_mode);
64 cx.capture_action(ProjectSearchBar::tab);
65 cx.capture_action(ProjectSearchBar::tab_previous);
66 add_toggle_option_action::<ToggleCaseSensitive>(SearchOptions::CASE_SENSITIVE, cx);
67 add_toggle_option_action::<ToggleWholeWord>(SearchOptions::WHOLE_WORD, cx);
68 add_toggle_filters_action::<ToggleFilters>(cx);
69}
70
71fn add_toggle_filters_action<A: Action>(cx: &mut AppContext) {
72 cx.add_action(move |pane: &mut Pane, _: &A, cx: &mut ViewContext<Pane>| {
73 if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<ProjectSearchBar>() {
74 if search_bar.update(cx, |search_bar, cx| search_bar.toggle_filters(cx)) {
75 return;
76 }
77 }
78 cx.propagate_action();
79 });
80}
81
82fn add_toggle_option_action<A: Action>(option: SearchOptions, cx: &mut AppContext) {
83 cx.add_action(move |pane: &mut Pane, _: &A, cx: &mut ViewContext<Pane>| {
84 if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<ProjectSearchBar>() {
85 if search_bar.update(cx, |search_bar, cx| {
86 search_bar.toggle_search_option(option, cx)
87 }) {
88 return;
89 }
90 }
91 cx.propagate_action();
92 });
93}
94
95struct ProjectSearch {
96 project: ModelHandle<Project>,
97 excerpts: ModelHandle<MultiBuffer>,
98 pending_search: Option<Task<Option<()>>>,
99 match_ranges: Vec<Range<Anchor>>,
100 active_query: Option<SearchQuery>,
101 search_id: usize,
102 search_history: SearchHistory,
103 no_results: Option<bool>,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
107enum InputPanel {
108 Query,
109 Exclude,
110 Include,
111}
112
113pub struct ProjectSearchView {
114 model: ModelHandle<ProjectSearch>,
115 query_editor: ViewHandle<Editor>,
116 results_editor: ViewHandle<Editor>,
117 search_options: SearchOptions,
118 panels_with_errors: HashSet<InputPanel>,
119 active_match_index: Option<usize>,
120 search_id: usize,
121 query_editor_was_focused: bool,
122 included_files_editor: ViewHandle<Editor>,
123 excluded_files_editor: ViewHandle<Editor>,
124 filters_enabled: bool,
125 current_mode: SearchMode,
126}
127
128pub struct ProjectSearchBar {
129 active_project_search: Option<ViewHandle<ProjectSearchView>>,
130 subscription: Option<Subscription>,
131}
132
133impl Entity for ProjectSearch {
134 type Event = ();
135}
136
137impl ProjectSearch {
138 fn new(project: ModelHandle<Project>, cx: &mut ModelContext<Self>) -> Self {
139 let replica_id = project.read(cx).replica_id();
140 Self {
141 project,
142 excerpts: cx.add_model(|_| MultiBuffer::new(replica_id)),
143 pending_search: Default::default(),
144 match_ranges: Default::default(),
145 active_query: None,
146 search_id: 0,
147 search_history: SearchHistory::default(),
148 no_results: None,
149 }
150 }
151
152 fn clone(&self, cx: &mut ModelContext<Self>) -> ModelHandle<Self> {
153 cx.add_model(|cx| Self {
154 project: self.project.clone(),
155 excerpts: self
156 .excerpts
157 .update(cx, |excerpts, cx| cx.add_model(|cx| excerpts.clone(cx))),
158 pending_search: Default::default(),
159 match_ranges: self.match_ranges.clone(),
160 active_query: self.active_query.clone(),
161 search_id: self.search_id,
162 search_history: self.search_history.clone(),
163 no_results: self.no_results.clone(),
164 })
165 }
166
167 fn search(&mut self, query: SearchQuery, cx: &mut ModelContext<Self>) {
168 let search = self
169 .project
170 .update(cx, |project, cx| project.search(query.clone(), cx));
171 self.search_id += 1;
172 self.search_history.add(query.as_str().to_string());
173 self.active_query = Some(query);
174 self.match_ranges.clear();
175 self.pending_search = Some(cx.spawn_weak(|this, mut cx| async move {
176 let matches = search.await.log_err()?;
177 let this = this.upgrade(&cx)?;
178 let mut matches = matches.into_iter().collect::<Vec<_>>();
179 let (_task, mut match_ranges) = this.update(&mut cx, |this, cx| {
180 this.match_ranges.clear();
181 this.no_results = Some(true);
182 matches.sort_by_key(|(buffer, _)| buffer.read(cx).file().map(|file| file.path()));
183 this.excerpts.update(cx, |excerpts, cx| {
184 excerpts.clear(cx);
185 excerpts.stream_excerpts_with_context_lines(matches, 1, cx)
186 })
187 });
188
189 while let Some(match_range) = match_ranges.next().await {
190 this.update(&mut cx, |this, cx| {
191 this.match_ranges.push(match_range);
192 while let Ok(Some(match_range)) = match_ranges.try_next() {
193 this.match_ranges.push(match_range);
194 }
195 this.no_results = Some(false);
196 cx.notify();
197 });
198 }
199
200 this.update(&mut cx, |this, cx| {
201 this.pending_search.take();
202 cx.notify();
203 });
204
205 None
206 }));
207 cx.notify();
208 }
209}
210
211#[derive(Clone, Debug, PartialEq, Eq)]
212pub enum ViewEvent {
213 UpdateTab,
214 Activate,
215 EditorEvent(editor::Event),
216 Dismiss,
217}
218
219impl Entity for ProjectSearchView {
220 type Event = ViewEvent;
221}
222
223impl View for ProjectSearchView {
224 fn ui_name() -> &'static str {
225 "ProjectSearchView"
226 }
227
228 fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
229 let model = &self.model.read(cx);
230 if model.match_ranges.is_empty() {
231 enum Status {}
232
233 let theme = theme::current(cx).clone();
234
235 // If Search is Active -> Major: Searching..., Minor: None
236 // If Semantic -> Major: "Search using Natural Language", Minor: {Status}/n{ex...}/n{ex...}
237 // If Regex -> Major: "Search using Regex", Minor: {ex...}
238 // If Text -> Major: "Text search all files and folders", Minor: {...}
239
240 let current_mode = self.current_mode;
241 let major_text = if model.pending_search.is_some() {
242 Cow::Borrowed("Searching...")
243 } else if model.no_results.is_some_and(|v| v) {
244 Cow::Borrowed("No Results")
245 } else {
246 match current_mode {
247 SearchMode::Text => Cow::Borrowed("Text search all files and folders"),
248 SearchMode::Regex => Cow::Borrowed("Regex search all files and folders"),
249 }
250 };
251
252 let minor_text = if let Some(no_results) = model.no_results {
253 if model.pending_search.is_none() && no_results {
254 vec!["No results found in this project for the provided query".to_owned()]
255 } else {
256 vec![]
257 }
258 } else {
259 vec![
260 "".to_owned(),
261 "Include/exclude specific paths with the filter option.".to_owned(),
262 "Matching exact word and/or casing is available too.".to_owned(),
263 ]
264 };
265
266 let previous_query_keystrokes =
267 cx.binding_for_action(&PreviousHistoryQuery {})
268 .map(|binding| {
269 binding
270 .keystrokes()
271 .iter()
272 .map(|k| k.to_string())
273 .collect::<Vec<_>>()
274 });
275 let next_query_keystrokes =
276 cx.binding_for_action(&NextHistoryQuery {}).map(|binding| {
277 binding
278 .keystrokes()
279 .iter()
280 .map(|k| k.to_string())
281 .collect::<Vec<_>>()
282 });
283 let new_placeholder_text = match (previous_query_keystrokes, next_query_keystrokes) {
284 (Some(previous_query_keystrokes), Some(next_query_keystrokes)) => {
285 format!(
286 "Search ({}/{} for previous/next query)",
287 previous_query_keystrokes.join(" "),
288 next_query_keystrokes.join(" ")
289 )
290 }
291 (None, Some(next_query_keystrokes)) => {
292 format!(
293 "Search ({} for next query)",
294 next_query_keystrokes.join(" ")
295 )
296 }
297 (Some(previous_query_keystrokes), None) => {
298 format!(
299 "Search ({} for previous query)",
300 previous_query_keystrokes.join(" ")
301 )
302 }
303 (None, None) => String::new(),
304 };
305 self.query_editor.update(cx, |editor, cx| {
306 editor.set_placeholder_text(new_placeholder_text, cx);
307 });
308
309 MouseEventHandler::new::<Status, _>(0, cx, |_, _| {
310 Flex::column()
311 .with_child(Flex::column().contained().flex(1., true))
312 .with_child(
313 Flex::column()
314 .align_children_center()
315 .with_child(Label::new(
316 major_text,
317 theme.search.major_results_status.clone(),
318 ))
319 .with_children(
320 minor_text.into_iter().map(|x| {
321 Label::new(x, theme.search.minor_results_status.clone())
322 }),
323 )
324 .aligned()
325 .top()
326 .contained()
327 .flex(7., true),
328 )
329 .contained()
330 .with_background_color(theme.editor.background)
331 })
332 .on_down(MouseButton::Left, |_, _, cx| {
333 cx.focus_parent();
334 })
335 .into_any_named("project search view")
336 } else {
337 ChildView::new(&self.results_editor, cx)
338 .flex(1., true)
339 .into_any_named("project search view")
340 }
341 }
342
343 fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
344 let handle = cx.weak_handle();
345 cx.update_global(|state: &mut ActiveSearches, cx| {
346 state
347 .0
348 .insert(self.model.read(cx).project.downgrade(), handle)
349 });
350
351 if cx.is_self_focused() {
352 if self.query_editor_was_focused {
353 cx.focus(&self.query_editor);
354 } else {
355 cx.focus(&self.results_editor);
356 }
357 }
358 }
359}
360
361impl Item for ProjectSearchView {
362 fn tab_tooltip_text(&self, cx: &AppContext) -> Option<Cow<str>> {
363 let query_text = self.query_editor.read(cx).text(cx);
364
365 query_text
366 .is_empty()
367 .not()
368 .then(|| query_text.into())
369 .or_else(|| Some("Project Search".into()))
370 }
371 fn should_close_item_on_event(event: &Self::Event) -> bool {
372 event == &Self::Event::Dismiss
373 }
374 fn act_as_type<'a>(
375 &'a self,
376 type_id: TypeId,
377 self_handle: &'a ViewHandle<Self>,
378 _: &'a AppContext,
379 ) -> Option<&'a AnyViewHandle> {
380 if type_id == TypeId::of::<Self>() {
381 Some(self_handle)
382 } else if type_id == TypeId::of::<Editor>() {
383 Some(&self.results_editor)
384 } else {
385 None
386 }
387 }
388
389 fn deactivated(&mut self, cx: &mut ViewContext<Self>) {
390 self.results_editor
391 .update(cx, |editor, cx| editor.deactivated(cx));
392 }
393
394 fn tab_content<T: View>(
395 &self,
396 _detail: Option<usize>,
397 tab_theme: &theme::Tab,
398 cx: &AppContext,
399 ) -> AnyElement<T> {
400 Flex::row()
401 .with_child(
402 Svg::new("icons/magnifying_glass_12.svg")
403 .with_color(tab_theme.label.text.color)
404 .constrained()
405 .with_width(tab_theme.type_icon_width)
406 .aligned()
407 .contained()
408 .with_margin_right(tab_theme.spacing),
409 )
410 .with_child({
411 let tab_name: Option<Cow<_>> =
412 self.model.read(cx).active_query.as_ref().map(|query| {
413 let query_text =
414 util::truncate_and_trailoff(query.as_str(), MAX_TAB_TITLE_LEN);
415 query_text.into()
416 });
417 Label::new(
418 tab_name
419 .filter(|name| !name.is_empty())
420 .unwrap_or("Project search".into()),
421 tab_theme.label.clone(),
422 )
423 .aligned()
424 })
425 .into_any()
426 }
427
428 fn for_each_project_item(&self, cx: &AppContext, f: &mut dyn FnMut(usize, &dyn project::Item)) {
429 self.results_editor.for_each_project_item(cx, f)
430 }
431
432 fn is_singleton(&self, _: &AppContext) -> bool {
433 false
434 }
435
436 fn can_save(&self, _: &AppContext) -> bool {
437 true
438 }
439
440 fn is_dirty(&self, cx: &AppContext) -> bool {
441 self.results_editor.read(cx).is_dirty(cx)
442 }
443
444 fn has_conflict(&self, cx: &AppContext) -> bool {
445 self.results_editor.read(cx).has_conflict(cx)
446 }
447
448 fn save(
449 &mut self,
450 project: ModelHandle<Project>,
451 cx: &mut ViewContext<Self>,
452 ) -> Task<anyhow::Result<()>> {
453 self.results_editor
454 .update(cx, |editor, cx| editor.save(project, cx))
455 }
456
457 fn save_as(
458 &mut self,
459 _: ModelHandle<Project>,
460 _: PathBuf,
461 _: &mut ViewContext<Self>,
462 ) -> Task<anyhow::Result<()>> {
463 unreachable!("save_as should not have been called")
464 }
465
466 fn reload(
467 &mut self,
468 project: ModelHandle<Project>,
469 cx: &mut ViewContext<Self>,
470 ) -> Task<anyhow::Result<()>> {
471 self.results_editor
472 .update(cx, |editor, cx| editor.reload(project, cx))
473 }
474
475 fn clone_on_split(&self, _workspace_id: WorkspaceId, cx: &mut ViewContext<Self>) -> Option<Self>
476 where
477 Self: Sized,
478 {
479 let model = self.model.update(cx, |model, cx| model.clone(cx));
480 Some(Self::new(model, cx))
481 }
482
483 fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext<Self>) {
484 self.results_editor
485 .update(cx, |editor, cx| editor.added_to_workspace(workspace, cx));
486 }
487
488 fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut ViewContext<Self>) {
489 self.results_editor.update(cx, |editor, _| {
490 editor.set_nav_history(Some(nav_history));
491 });
492 }
493
494 fn navigate(&mut self, data: Box<dyn Any>, cx: &mut ViewContext<Self>) -> bool {
495 self.results_editor
496 .update(cx, |editor, cx| editor.navigate(data, cx))
497 }
498
499 fn to_item_events(event: &Self::Event) -> SmallVec<[ItemEvent; 2]> {
500 match event {
501 ViewEvent::UpdateTab => {
502 smallvec::smallvec![ItemEvent::UpdateBreadcrumbs, ItemEvent::UpdateTab]
503 }
504 ViewEvent::EditorEvent(editor_event) => Editor::to_item_events(editor_event),
505 ViewEvent::Dismiss => smallvec::smallvec![ItemEvent::CloseItem],
506 _ => SmallVec::new(),
507 }
508 }
509
510 fn breadcrumb_location(&self) -> ToolbarItemLocation {
511 if self.has_matches() {
512 ToolbarItemLocation::Secondary
513 } else {
514 ToolbarItemLocation::Hidden
515 }
516 }
517
518 fn breadcrumbs(&self, theme: &theme::Theme, cx: &AppContext) -> Option<Vec<BreadcrumbText>> {
519 self.results_editor.breadcrumbs(theme, cx)
520 }
521
522 fn serialized_item_kind() -> Option<&'static str> {
523 None
524 }
525
526 fn deserialize(
527 _project: ModelHandle<Project>,
528 _workspace: WeakViewHandle<Workspace>,
529 _workspace_id: workspace::WorkspaceId,
530 _item_id: workspace::ItemId,
531 _cx: &mut ViewContext<Pane>,
532 ) -> Task<anyhow::Result<ViewHandle<Self>>> {
533 unimplemented!()
534 }
535}
536
537impl ProjectSearchView {
538 fn toggle_search_option(&mut self, option: SearchOptions) {
539 self.search_options.toggle(option);
540 }
541
542 fn clear_search(&mut self, cx: &mut ViewContext<Self>) {
543 self.model.update(cx, |model, cx| {
544 model.pending_search = None;
545 model.no_results = None;
546 model.match_ranges.clear();
547
548 model.excerpts.update(cx, |excerpts, cx| {
549 excerpts.clear(cx);
550 });
551 });
552 }
553
554 fn activate_search_mode(&mut self, mode: SearchMode, cx: &mut ViewContext<Self>) {
555 let previous_mode = self.current_mode;
556 if previous_mode == mode {
557 return;
558 }
559
560 self.clear_search(cx);
561 self.current_mode = mode;
562 self.active_match_index = None;
563
564 self.search(cx);
565
566 cx.notify();
567 }
568
569 fn new(model: ModelHandle<ProjectSearch>, cx: &mut ViewContext<Self>) -> Self {
570 let project;
571 let excerpts;
572 let mut query_text = String::new();
573 let mut options = SearchOptions::NONE;
574
575 {
576 let model = model.read(cx);
577 project = model.project.clone();
578 excerpts = model.excerpts.clone();
579 if let Some(active_query) = model.active_query.as_ref() {
580 query_text = active_query.as_str().to_string();
581 options = SearchOptions::from_query(active_query);
582 }
583 }
584 cx.observe(&model, |this, _, cx| this.model_changed(cx))
585 .detach();
586
587 let query_editor = cx.add_view(|cx| {
588 let mut editor = Editor::single_line(
589 Some(Arc::new(|theme| theme.search.editor.input.clone())),
590 cx,
591 );
592 editor.set_placeholder_text("Text search all files", cx);
593 editor.set_text(query_text, cx);
594 editor
595 });
596 // Subscribe to query_editor in order to reraise editor events for workspace item activation purposes
597 cx.subscribe(&query_editor, |_, _, event, cx| {
598 cx.emit(ViewEvent::EditorEvent(event.clone()))
599 })
600 .detach();
601
602 let results_editor = cx.add_view(|cx| {
603 let mut editor = Editor::for_multibuffer(excerpts, Some(project.clone()), cx);
604 editor.set_searchable(false);
605 editor
606 });
607 cx.observe(&results_editor, |_, _, cx| cx.emit(ViewEvent::UpdateTab))
608 .detach();
609
610 cx.subscribe(&results_editor, |this, _, event, cx| {
611 if matches!(event, editor::Event::SelectionsChanged { .. }) {
612 this.update_match_index(cx);
613 }
614 // Reraise editor events for workspace item activation purposes
615 cx.emit(ViewEvent::EditorEvent(event.clone()));
616 })
617 .detach();
618
619 let included_files_editor = cx.add_view(|cx| {
620 let mut editor = Editor::single_line(
621 Some(Arc::new(|theme| {
622 theme.search.include_exclude_editor.input.clone()
623 })),
624 cx,
625 );
626 editor.set_placeholder_text("Include: crates/**/*.toml", cx);
627
628 editor
629 });
630 // Subscribe to include_files_editor in order to reraise editor events for workspace item activation purposes
631 cx.subscribe(&included_files_editor, |_, _, event, cx| {
632 cx.emit(ViewEvent::EditorEvent(event.clone()))
633 })
634 .detach();
635
636 let excluded_files_editor = cx.add_view(|cx| {
637 let mut editor = Editor::single_line(
638 Some(Arc::new(|theme| {
639 theme.search.include_exclude_editor.input.clone()
640 })),
641 cx,
642 );
643 editor.set_placeholder_text("Exclude: vendor/*, *.lock", cx);
644
645 editor
646 });
647 // Subscribe to excluded_files_editor in order to reraise editor events for workspace item activation purposes
648 cx.subscribe(&excluded_files_editor, |_, _, event, cx| {
649 cx.emit(ViewEvent::EditorEvent(event.clone()))
650 })
651 .detach();
652 let filters_enabled = false;
653
654 // Check if Worktrees have all been previously indexed
655 let mut this = ProjectSearchView {
656 search_id: model.read(cx).search_id,
657 model,
658 query_editor,
659 results_editor,
660 search_options: options,
661 panels_with_errors: HashSet::new(),
662 active_match_index: None,
663 query_editor_was_focused: false,
664 included_files_editor,
665 excluded_files_editor,
666 filters_enabled,
667 current_mode: Default::default(),
668 };
669 this.model_changed(cx);
670 this
671 }
672
673 pub fn new_search_in_directory(
674 workspace: &mut Workspace,
675 dir_entry: &Entry,
676 cx: &mut ViewContext<Workspace>,
677 ) {
678 if !dir_entry.is_dir() {
679 return;
680 }
681 let Some(filter_str) = dir_entry.path.to_str() else { return; };
682
683 let model = cx.add_model(|cx| ProjectSearch::new(workspace.project().clone(), cx));
684 let search = cx.add_view(|cx| ProjectSearchView::new(model, cx));
685 workspace.add_item(Box::new(search.clone()), cx);
686 search.update(cx, |search, cx| {
687 search
688 .included_files_editor
689 .update(cx, |editor, cx| editor.set_text(filter_str, cx));
690 search.focus_query_editor(cx)
691 });
692 }
693
694 // Re-activate the most recently activated search or the most recent if it has been closed.
695 // If no search exists in the workspace, create a new one.
696 fn deploy(
697 workspace: &mut Workspace,
698 _: &workspace::NewSearch,
699 cx: &mut ViewContext<Workspace>,
700 ) {
701 // Clean up entries for dropped projects
702 cx.update_global(|state: &mut ActiveSearches, cx| {
703 state.0.retain(|project, _| project.is_upgradable(cx))
704 });
705
706 let active_search = cx
707 .global::<ActiveSearches>()
708 .0
709 .get(&workspace.project().downgrade());
710
711 let existing = active_search
712 .and_then(|active_search| {
713 workspace
714 .items_of_type::<ProjectSearchView>(cx)
715 .find(|search| search == active_search)
716 })
717 .or_else(|| workspace.item_of_type::<ProjectSearchView>(cx));
718
719 let query = workspace.active_item(cx).and_then(|item| {
720 let editor = item.act_as::<Editor>(cx)?;
721 let query = editor.query_suggestion(cx);
722 if query.is_empty() {
723 None
724 } else {
725 Some(query)
726 }
727 });
728
729 let search = if let Some(existing) = existing {
730 workspace.activate_item(&existing, cx);
731 existing
732 } else {
733 let model = cx.add_model(|cx| ProjectSearch::new(workspace.project().clone(), cx));
734 let view = cx.add_view(|cx| ProjectSearchView::new(model, cx));
735 workspace.add_item(Box::new(view.clone()), cx);
736 view
737 };
738
739 search.update(cx, |search, cx| {
740 if let Some(query) = query {
741 search.set_query(&query, cx);
742 }
743 search.focus_query_editor(cx)
744 });
745 }
746
747 fn search(&mut self, cx: &mut ViewContext<Self>) {
748 if let Some(query) = self.build_search_query(cx) {
749 self.model.update(cx, |model, cx| model.search(query, cx));
750 }
751 }
752
753 fn build_search_query(&mut self, cx: &mut ViewContext<Self>) -> Option<SearchQuery> {
754 let text = self.query_editor.read(cx).text(cx);
755 let included_files =
756 match Self::parse_path_matches(&self.included_files_editor.read(cx).text(cx)) {
757 Ok(included_files) => {
758 self.panels_with_errors.remove(&InputPanel::Include);
759 included_files
760 }
761 Err(_e) => {
762 self.panels_with_errors.insert(InputPanel::Include);
763 cx.notify();
764 return None;
765 }
766 };
767 let excluded_files =
768 match Self::parse_path_matches(&self.excluded_files_editor.read(cx).text(cx)) {
769 Ok(excluded_files) => {
770 self.panels_with_errors.remove(&InputPanel::Exclude);
771 excluded_files
772 }
773 Err(_e) => {
774 self.panels_with_errors.insert(InputPanel::Exclude);
775 cx.notify();
776 return None;
777 }
778 };
779 let current_mode = self.current_mode;
780 match current_mode {
781 SearchMode::Regex => {
782 match SearchQuery::regex(
783 text,
784 self.search_options.contains(SearchOptions::WHOLE_WORD),
785 self.search_options.contains(SearchOptions::CASE_SENSITIVE),
786 included_files,
787 excluded_files,
788 ) {
789 Ok(query) => {
790 self.panels_with_errors.remove(&InputPanel::Query);
791 Some(query)
792 }
793 Err(_e) => {
794 self.panels_with_errors.insert(InputPanel::Query);
795 cx.notify();
796 None
797 }
798 }
799 }
800 _ => Some(SearchQuery::text(
801 text,
802 self.search_options.contains(SearchOptions::WHOLE_WORD),
803 self.search_options.contains(SearchOptions::CASE_SENSITIVE),
804 included_files,
805 excluded_files,
806 )),
807 }
808 }
809
810 fn parse_path_matches(text: &str) -> anyhow::Result<Vec<PathMatcher>> {
811 text.split(',')
812 .map(str::trim)
813 .filter(|maybe_glob_str| !maybe_glob_str.is_empty())
814 .map(|maybe_glob_str| {
815 PathMatcher::new(maybe_glob_str)
816 .with_context(|| format!("parsing {maybe_glob_str} as path matcher"))
817 })
818 .collect()
819 }
820
821 fn select_match(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
822 if let Some(index) = self.active_match_index {
823 let match_ranges = self.model.read(cx).match_ranges.clone();
824 let new_index = self.results_editor.update(cx, |editor, cx| {
825 editor.match_index_for_direction(&match_ranges, index, direction, 1, cx)
826 });
827
828 let range_to_select = match_ranges[new_index].clone();
829 self.results_editor.update(cx, |editor, cx| {
830 let range_to_select = editor.range_for_match(&range_to_select);
831 editor.unfold_ranges([range_to_select.clone()], false, true, cx);
832 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
833 s.select_ranges([range_to_select])
834 });
835 });
836 }
837 }
838
839 fn focus_query_editor(&mut self, cx: &mut ViewContext<Self>) {
840 self.query_editor.update(cx, |query_editor, cx| {
841 query_editor.select_all(&SelectAll, cx);
842 });
843 self.query_editor_was_focused = true;
844 cx.focus(&self.query_editor);
845 }
846
847 fn set_query(&mut self, query: &str, cx: &mut ViewContext<Self>) {
848 self.query_editor
849 .update(cx, |query_editor, cx| query_editor.set_text(query, cx));
850 }
851
852 fn focus_results_editor(&mut self, cx: &mut ViewContext<Self>) {
853 self.query_editor.update(cx, |query_editor, cx| {
854 let cursor = query_editor.selections.newest_anchor().head();
855 query_editor.change_selections(None, cx, |s| s.select_ranges([cursor.clone()..cursor]));
856 });
857 self.query_editor_was_focused = false;
858 cx.focus(&self.results_editor);
859 }
860
861 fn model_changed(&mut self, cx: &mut ViewContext<Self>) {
862 let match_ranges = self.model.read(cx).match_ranges.clone();
863 if match_ranges.is_empty() {
864 self.active_match_index = None;
865 } else {
866 self.active_match_index = Some(0);
867 self.update_match_index(cx);
868 let prev_search_id = mem::replace(&mut self.search_id, self.model.read(cx).search_id);
869 let is_new_search = self.search_id != prev_search_id;
870 self.results_editor.update(cx, |editor, cx| {
871 if is_new_search {
872 let range_to_select = match_ranges
873 .first()
874 .clone()
875 .map(|range| editor.range_for_match(range));
876 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
877 s.select_ranges(range_to_select)
878 });
879 }
880 editor.highlight_background::<Self>(
881 match_ranges,
882 |theme| theme.search.match_background,
883 cx,
884 );
885 });
886 if is_new_search && self.query_editor.is_focused(cx) {
887 self.focus_results_editor(cx);
888 }
889 }
890
891 cx.emit(ViewEvent::UpdateTab);
892 cx.notify();
893 }
894
895 fn update_match_index(&mut self, cx: &mut ViewContext<Self>) {
896 let results_editor = self.results_editor.read(cx);
897 let new_index = active_match_index(
898 &self.model.read(cx).match_ranges,
899 &results_editor.selections.newest_anchor().head(),
900 &results_editor.buffer().read(cx).snapshot(cx),
901 );
902 if self.active_match_index != new_index {
903 self.active_match_index = new_index;
904 cx.notify();
905 }
906 }
907
908 pub fn has_matches(&self) -> bool {
909 self.active_match_index.is_some()
910 }
911
912 fn move_focus_to_results(pane: &mut Pane, _: &ToggleFocus, cx: &mut ViewContext<Pane>) {
913 if let Some(search_view) = pane
914 .active_item()
915 .and_then(|item| item.downcast::<ProjectSearchView>())
916 {
917 search_view.update(cx, |search_view, cx| {
918 if !search_view.results_editor.is_focused(cx)
919 && !search_view.model.read(cx).match_ranges.is_empty()
920 {
921 return search_view.focus_results_editor(cx);
922 }
923 });
924 }
925
926 cx.propagate_action();
927 }
928}
929
930impl Default for ProjectSearchBar {
931 fn default() -> Self {
932 Self::new()
933 }
934}
935
936impl ProjectSearchBar {
937 pub fn new() -> Self {
938 Self {
939 active_project_search: Default::default(),
940 subscription: Default::default(),
941 }
942 }
943 fn cycle_mode(workspace: &mut Workspace, _: &CycleMode, cx: &mut ViewContext<Workspace>) {
944 if let Some(search_view) = workspace
945 .active_item(cx)
946 .and_then(|item| item.downcast::<ProjectSearchView>())
947 {
948 search_view.update(cx, |this, cx| {
949 let new_mode = crate::mode::next_mode(&this.current_mode);
950 this.activate_search_mode(new_mode, cx);
951 cx.focus(&this.query_editor);
952 })
953 }
954 }
955 fn search(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
956 if let Some(search_view) = self.active_project_search.as_ref() {
957 search_view.update(cx, |search_view, cx| search_view.search(cx));
958 }
959 }
960
961 fn search_in_new(workspace: &mut Workspace, _: &SearchInNew, cx: &mut ViewContext<Workspace>) {
962 if let Some(search_view) = workspace
963 .active_item(cx)
964 .and_then(|item| item.downcast::<ProjectSearchView>())
965 {
966 let new_query = search_view.update(cx, |search_view, cx| {
967 let new_query = search_view.build_search_query(cx);
968 if new_query.is_some() {
969 if let Some(old_query) = search_view.model.read(cx).active_query.clone() {
970 search_view.query_editor.update(cx, |editor, cx| {
971 editor.set_text(old_query.as_str(), cx);
972 });
973 search_view.search_options = SearchOptions::from_query(&old_query);
974 }
975 }
976 new_query
977 });
978 if let Some(new_query) = new_query {
979 let model = cx.add_model(|cx| {
980 let mut model = ProjectSearch::new(workspace.project().clone(), cx);
981 model.search(new_query, cx);
982 model
983 });
984 workspace.add_item(
985 Box::new(cx.add_view(|cx| ProjectSearchView::new(model, cx))),
986 cx,
987 );
988 }
989 }
990 }
991
992 fn select_next_match(pane: &mut Pane, _: &SelectNextMatch, cx: &mut ViewContext<Pane>) {
993 if let Some(search_view) = pane
994 .active_item()
995 .and_then(|item| item.downcast::<ProjectSearchView>())
996 {
997 search_view.update(cx, |view, cx| view.select_match(Direction::Next, cx));
998 } else {
999 cx.propagate_action();
1000 }
1001 }
1002
1003 fn select_prev_match(pane: &mut Pane, _: &SelectPrevMatch, cx: &mut ViewContext<Pane>) {
1004 if let Some(search_view) = pane
1005 .active_item()
1006 .and_then(|item| item.downcast::<ProjectSearchView>())
1007 {
1008 search_view.update(cx, |view, cx| view.select_match(Direction::Prev, cx));
1009 } else {
1010 cx.propagate_action();
1011 }
1012 }
1013
1014 fn tab(&mut self, _: &editor::Tab, cx: &mut ViewContext<Self>) {
1015 self.cycle_field(Direction::Next, cx);
1016 }
1017
1018 fn tab_previous(&mut self, _: &editor::TabPrev, cx: &mut ViewContext<Self>) {
1019 self.cycle_field(Direction::Prev, cx);
1020 }
1021
1022 fn cycle_field(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
1023 let active_project_search = match &self.active_project_search {
1024 Some(active_project_search) => active_project_search,
1025
1026 None => {
1027 cx.propagate_action();
1028 return;
1029 }
1030 };
1031
1032 active_project_search.update(cx, |project_view, cx| {
1033 let views = &[
1034 &project_view.query_editor,
1035 &project_view.included_files_editor,
1036 &project_view.excluded_files_editor,
1037 ];
1038
1039 let current_index = match views
1040 .iter()
1041 .enumerate()
1042 .find(|(_, view)| view.is_focused(cx))
1043 {
1044 Some((index, _)) => index,
1045
1046 None => {
1047 cx.propagate_action();
1048 return;
1049 }
1050 };
1051
1052 let new_index = match direction {
1053 Direction::Next => (current_index + 1) % views.len(),
1054 Direction::Prev if current_index == 0 => views.len() - 1,
1055 Direction::Prev => (current_index - 1) % views.len(),
1056 };
1057 cx.focus(views[new_index]);
1058 });
1059 }
1060
1061 fn toggle_search_option(&mut self, option: SearchOptions, cx: &mut ViewContext<Self>) -> bool {
1062 if let Some(search_view) = self.active_project_search.as_ref() {
1063 search_view.update(cx, |search_view, cx| {
1064 search_view.toggle_search_option(option);
1065 search_view.search(cx);
1066 });
1067 cx.notify();
1068 true
1069 } else {
1070 false
1071 }
1072 }
1073
1074 // fn activate_regex_mode(pane: &mut Pane, _: &ActivateRegexMode, cx: &mut ViewContext<Pane>) {
1075 // if let Some(search_view) = pane
1076 // .active_item()
1077 // .and_then(|item| item.downcast::<ProjectSearchView>())
1078 // {
1079 // search_view.update(cx, |view, cx| {
1080 // view.activate_search_mode(SearchMode::Regex, cx)
1081 // });
1082 // } else {
1083 // cx.propagate_action();
1084 // }
1085 // }
1086
1087 fn toggle_filters(&mut self, cx: &mut ViewContext<Self>) -> bool {
1088 if let Some(search_view) = self.active_project_search.as_ref() {
1089 search_view.update(cx, |search_view, cx| {
1090 search_view.filters_enabled = !search_view.filters_enabled;
1091 search_view
1092 .included_files_editor
1093 .update(cx, |_, cx| cx.notify());
1094 search_view
1095 .excluded_files_editor
1096 .update(cx, |_, cx| cx.notify());
1097 cx.refresh_windows();
1098 cx.notify();
1099 });
1100 cx.notify();
1101 true
1102 } else {
1103 false
1104 }
1105 }
1106
1107 fn activate_search_mode(&self, mode: SearchMode, cx: &mut ViewContext<Self>) {
1108 // Update Current Mode
1109 if let Some(search_view) = self.active_project_search.as_ref() {
1110 search_view.update(cx, |search_view, cx| {
1111 search_view.activate_search_mode(mode, cx);
1112 });
1113 cx.notify();
1114 }
1115 }
1116
1117 fn is_option_enabled(&self, option: SearchOptions, cx: &AppContext) -> bool {
1118 if let Some(search) = self.active_project_search.as_ref() {
1119 search.read(cx).search_options.contains(option)
1120 } else {
1121 false
1122 }
1123 }
1124
1125 fn next_history_query(&mut self, _: &NextHistoryQuery, cx: &mut ViewContext<Self>) {
1126 if let Some(search_view) = self.active_project_search.as_ref() {
1127 search_view.update(cx, |search_view, cx| {
1128 let new_query = search_view.model.update(cx, |model, _| {
1129 if let Some(new_query) = model.search_history.next().map(str::to_string) {
1130 new_query
1131 } else {
1132 model.search_history.reset_selection();
1133 String::new()
1134 }
1135 });
1136 search_view.set_query(&new_query, cx);
1137 });
1138 }
1139 }
1140
1141 fn previous_history_query(&mut self, _: &PreviousHistoryQuery, cx: &mut ViewContext<Self>) {
1142 if let Some(search_view) = self.active_project_search.as_ref() {
1143 search_view.update(cx, |search_view, cx| {
1144 if search_view.query_editor.read(cx).text(cx).is_empty() {
1145 if let Some(new_query) = search_view
1146 .model
1147 .read(cx)
1148 .search_history
1149 .current()
1150 .map(str::to_string)
1151 {
1152 search_view.set_query(&new_query, cx);
1153 return;
1154 }
1155 }
1156
1157 if let Some(new_query) = search_view.model.update(cx, |model, _| {
1158 model.search_history.previous().map(str::to_string)
1159 }) {
1160 search_view.set_query(&new_query, cx);
1161 }
1162 });
1163 }
1164 }
1165}
1166
1167impl Entity for ProjectSearchBar {
1168 type Event = ();
1169}
1170
1171impl View for ProjectSearchBar {
1172 fn ui_name() -> &'static str {
1173 "ProjectSearchBar"
1174 }
1175
1176 fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
1177 if let Some(_search) = self.active_project_search.as_ref() {
1178 let search = _search.read(cx);
1179 let theme = theme::current(cx).clone();
1180 let query_container_style = if search.panels_with_errors.contains(&InputPanel::Query) {
1181 theme.search.invalid_editor
1182 } else {
1183 theme.search.editor.input.container
1184 };
1185
1186 let search = _search.read(cx);
1187 let filter_button = render_option_button_icon(
1188 search.filters_enabled,
1189 "icons/filter_12.svg",
1190 0,
1191 "Toggle filters",
1192 Box::new(ToggleFilters),
1193 move |_, this, cx| {
1194 this.toggle_filters(cx);
1195 },
1196 cx,
1197 );
1198
1199 let render_option_button_icon = |path, option, cx: &mut ViewContext<Self>| {
1200 crate::search_bar::render_option_button_icon(
1201 self.is_option_enabled(option, cx),
1202 path,
1203 option.bits as usize,
1204 format!("Toggle {}", option.label()),
1205 option.to_toggle_action(),
1206 move |_, this, cx| {
1207 this.toggle_search_option(option, cx);
1208 },
1209 cx,
1210 )
1211 };
1212 let case_sensitive = render_option_button_icon(
1213 "icons/case_insensitive_12.svg",
1214 SearchOptions::CASE_SENSITIVE,
1215 cx,
1216 );
1217
1218 let whole_word = render_option_button_icon(
1219 "icons/word_search_12.svg",
1220 SearchOptions::WHOLE_WORD,
1221 cx,
1222 );
1223
1224 let search = _search.read(cx);
1225 let icon_style = theme.search.editor_icon.clone();
1226
1227 // Editor Functionality
1228 let query = Flex::row()
1229 .with_child(
1230 Svg::for_style(icon_style.icon)
1231 .contained()
1232 .with_style(icon_style.container),
1233 )
1234 .with_child(ChildView::new(&search.query_editor, cx).flex(1., true))
1235 .with_child(
1236 Flex::row()
1237 .with_child(filter_button)
1238 .with_child(case_sensitive)
1239 .with_child(whole_word)
1240 .flex(1., false)
1241 .constrained()
1242 .contained(),
1243 )
1244 .align_children_center()
1245 .flex(1., true);
1246
1247 let search = _search.read(cx);
1248
1249 let include_container_style =
1250 if search.panels_with_errors.contains(&InputPanel::Include) {
1251 theme.search.invalid_include_exclude_editor
1252 } else {
1253 theme.search.include_exclude_editor.input.container
1254 };
1255
1256 let exclude_container_style =
1257 if search.panels_with_errors.contains(&InputPanel::Exclude) {
1258 theme.search.invalid_include_exclude_editor
1259 } else {
1260 theme.search.include_exclude_editor.input.container
1261 };
1262
1263 let included_files_view = ChildView::new(&search.included_files_editor, cx)
1264 .contained()
1265 .flex(1., true);
1266 let excluded_files_view = ChildView::new(&search.excluded_files_editor, cx)
1267 .contained()
1268 .flex(1., true);
1269 let filters = search.filters_enabled.then(|| {
1270 Flex::row()
1271 .with_child(
1272 included_files_view
1273 .contained()
1274 .with_style(include_container_style)
1275 .constrained()
1276 .with_height(theme.search.search_bar_row_height)
1277 .with_min_width(theme.search.include_exclude_editor.min_width)
1278 .with_max_width(theme.search.include_exclude_editor.max_width),
1279 )
1280 .with_child(
1281 excluded_files_view
1282 .contained()
1283 .with_style(exclude_container_style)
1284 .constrained()
1285 .with_height(theme.search.search_bar_row_height)
1286 .with_min_width(theme.search.include_exclude_editor.min_width)
1287 .with_max_width(theme.search.include_exclude_editor.max_width),
1288 )
1289 .contained()
1290 .with_padding_top(theme.workspace.toolbar.container.padding.bottom)
1291 });
1292
1293 let editor_column = Flex::column()
1294 .with_child(
1295 query
1296 .contained()
1297 .with_style(query_container_style)
1298 .constrained()
1299 .with_min_width(theme.search.editor.min_width)
1300 .with_max_width(theme.search.editor.max_width)
1301 .with_height(theme.search.search_bar_row_height)
1302 .flex(1., false),
1303 )
1304 .with_children(filters)
1305 .flex(1., false);
1306
1307 let matches = search.active_match_index.map(|match_ix| {
1308 Label::new(
1309 format!(
1310 "{}/{}",
1311 match_ix + 1,
1312 search.model.read(cx).match_ranges.len()
1313 ),
1314 theme.search.match_index.text.clone(),
1315 )
1316 .contained()
1317 .with_style(theme.search.match_index.container)
1318 .aligned()
1319 });
1320
1321 let search_button_for_mode = |mode, cx: &mut ViewContext<ProjectSearchBar>| {
1322 let is_active = if let Some(search) = self.active_project_search.as_ref() {
1323 let search = search.read(cx);
1324 search.current_mode == mode
1325 } else {
1326 false
1327 };
1328 render_search_mode_button(
1329 mode,
1330 is_active,
1331 move |_, this, cx| {
1332 this.activate_search_mode(mode, cx);
1333 },
1334 cx,
1335 )
1336 };
1337 let is_active = search.active_match_index.is_some();
1338
1339 let nav_button_for_direction = |label, direction, cx: &mut ViewContext<Self>| {
1340 render_nav_button(
1341 label,
1342 direction,
1343 is_active,
1344 move |_, this, cx| {
1345 if let Some(search) = this.active_project_search.as_ref() {
1346 search.update(cx, |search, cx| search.select_match(direction, cx));
1347 }
1348 },
1349 cx,
1350 )
1351 };
1352
1353 let nav_column = Flex::row()
1354 .with_child(nav_button_for_direction("<", Direction::Prev, cx))
1355 .with_child(nav_button_for_direction(">", Direction::Next, cx))
1356 .with_child(Flex::row().with_children(matches))
1357 .constrained()
1358 .with_height(theme.search.search_bar_row_height);
1359
1360 let mode_column = Flex::row()
1361 .with_child(
1362 Flex::row()
1363 .with_child(search_button_for_mode(SearchMode::Text, cx))
1364 .with_child(search_button_for_mode(SearchMode::Regex, cx))
1365 .contained()
1366 .with_style(theme.search.modes_container),
1367 )
1368 .with_child(super::search_bar::render_close_button(
1369 "Dismiss Project Search",
1370 &theme.search,
1371 cx,
1372 |_, this, cx| {
1373 if let Some(search) = this.active_project_search.as_mut() {
1374 search.update(cx, |_, cx| cx.emit(ViewEvent::Dismiss))
1375 }
1376 },
1377 None,
1378 ))
1379 .constrained()
1380 .with_height(theme.search.search_bar_row_height)
1381 .aligned()
1382 .right()
1383 .top()
1384 .flex_float();
1385
1386 Flex::row()
1387 .with_child(editor_column)
1388 .with_child(nav_column)
1389 .with_child(mode_column)
1390 .contained()
1391 .with_style(theme.search.container)
1392 .into_any_named("project search")
1393 } else {
1394 Empty::new().into_any()
1395 }
1396 }
1397}
1398
1399impl ToolbarItemView for ProjectSearchBar {
1400 fn set_active_pane_item(
1401 &mut self,
1402 active_pane_item: Option<&dyn ItemHandle>,
1403 cx: &mut ViewContext<Self>,
1404 ) -> ToolbarItemLocation {
1405 cx.notify();
1406 self.subscription = None;
1407 self.active_project_search = None;
1408 if let Some(search) = active_pane_item.and_then(|i| i.downcast::<ProjectSearchView>()) {
1409 self.subscription = Some(cx.observe(&search, |_, _, cx| cx.notify()));
1410 self.active_project_search = Some(search);
1411 ToolbarItemLocation::PrimaryLeft {
1412 flex: Some((1., false)),
1413 }
1414 } else {
1415 ToolbarItemLocation::Hidden
1416 }
1417 }
1418
1419 fn row_count(&self, cx: &ViewContext<Self>) -> usize {
1420 self.active_project_search
1421 .as_ref()
1422 .map(|search| {
1423 let offset = search.read(cx).filters_enabled as usize;
1424 2 + offset
1425 })
1426 .unwrap_or_else(|| 2)
1427 }
1428}
1429
1430#[cfg(test)]
1431pub mod tests {
1432 use super::*;
1433 use editor::DisplayPoint;
1434 use gpui::{color::Color, executor::Deterministic, TestAppContext};
1435 use project::FakeFs;
1436 use semantic_index::semantic_index_settings::SemanticIndexSettings;
1437 use serde_json::json;
1438 use settings::SettingsStore;
1439 use std::sync::Arc;
1440 use theme::ThemeSettings;
1441
1442 #[gpui::test]
1443 async fn test_project_search(deterministic: Arc<Deterministic>, cx: &mut TestAppContext) {
1444 init_test(cx);
1445
1446 let fs = FakeFs::new(cx.background());
1447 fs.insert_tree(
1448 "/dir",
1449 json!({
1450 "one.rs": "const ONE: usize = 1;",
1451 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
1452 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
1453 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
1454 }),
1455 )
1456 .await;
1457 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
1458 let search = cx.add_model(|cx| ProjectSearch::new(project, cx));
1459 let search_view = cx
1460 .add_window(|cx| ProjectSearchView::new(search.clone(), cx))
1461 .root(cx);
1462
1463 search_view.update(cx, |search_view, cx| {
1464 search_view
1465 .query_editor
1466 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
1467 search_view.search(cx);
1468 });
1469 deterministic.run_until_parked();
1470 search_view.update(cx, |search_view, cx| {
1471 assert_eq!(
1472 search_view
1473 .results_editor
1474 .update(cx, |editor, cx| editor.display_text(cx)),
1475 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;"
1476 );
1477 assert_eq!(
1478 search_view
1479 .results_editor
1480 .update(cx, |editor, cx| editor.all_background_highlights(cx)),
1481 &[
1482 (
1483 DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35),
1484 Color::red()
1485 ),
1486 (
1487 DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40),
1488 Color::red()
1489 ),
1490 (
1491 DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9),
1492 Color::red()
1493 )
1494 ]
1495 );
1496 assert_eq!(search_view.active_match_index, Some(0));
1497 assert_eq!(
1498 search_view
1499 .results_editor
1500 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1501 [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
1502 );
1503
1504 search_view.select_match(Direction::Next, cx);
1505 });
1506
1507 search_view.update(cx, |search_view, cx| {
1508 assert_eq!(search_view.active_match_index, Some(1));
1509 assert_eq!(
1510 search_view
1511 .results_editor
1512 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1513 [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
1514 );
1515 search_view.select_match(Direction::Next, cx);
1516 });
1517
1518 search_view.update(cx, |search_view, cx| {
1519 assert_eq!(search_view.active_match_index, Some(2));
1520 assert_eq!(
1521 search_view
1522 .results_editor
1523 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1524 [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
1525 );
1526 search_view.select_match(Direction::Next, cx);
1527 });
1528
1529 search_view.update(cx, |search_view, cx| {
1530 assert_eq!(search_view.active_match_index, Some(0));
1531 assert_eq!(
1532 search_view
1533 .results_editor
1534 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1535 [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
1536 );
1537 search_view.select_match(Direction::Prev, cx);
1538 });
1539
1540 search_view.update(cx, |search_view, cx| {
1541 assert_eq!(search_view.active_match_index, Some(2));
1542 assert_eq!(
1543 search_view
1544 .results_editor
1545 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1546 [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
1547 );
1548 search_view.select_match(Direction::Prev, cx);
1549 });
1550
1551 search_view.update(cx, |search_view, cx| {
1552 assert_eq!(search_view.active_match_index, Some(1));
1553 assert_eq!(
1554 search_view
1555 .results_editor
1556 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1557 [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
1558 );
1559 });
1560 }
1561
1562 #[gpui::test]
1563 async fn test_project_search_focus(deterministic: Arc<Deterministic>, cx: &mut TestAppContext) {
1564 init_test(cx);
1565
1566 let fs = FakeFs::new(cx.background());
1567 fs.insert_tree(
1568 "/dir",
1569 json!({
1570 "one.rs": "const ONE: usize = 1;",
1571 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
1572 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
1573 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
1574 }),
1575 )
1576 .await;
1577 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
1578 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
1579 let workspace = window.root(cx);
1580
1581 let active_item = cx.read(|cx| {
1582 workspace
1583 .read(cx)
1584 .active_pane()
1585 .read(cx)
1586 .active_item()
1587 .and_then(|item| item.downcast::<ProjectSearchView>())
1588 });
1589 assert!(
1590 active_item.is_none(),
1591 "Expected no search panel to be active, but got: {active_item:?}"
1592 );
1593
1594 workspace.update(cx, |workspace, cx| {
1595 ProjectSearchView::deploy(workspace, &workspace::NewSearch, cx)
1596 });
1597
1598 let Some(search_view) = cx.read(|cx| {
1599 workspace
1600 .read(cx)
1601 .active_pane()
1602 .read(cx)
1603 .active_item()
1604 .and_then(|item| item.downcast::<ProjectSearchView>())
1605 }) else {
1606 panic!("Search view expected to appear after new search event trigger")
1607 };
1608 let search_view_id = search_view.id();
1609
1610 cx.spawn(|mut cx| async move {
1611 window.dispatch_action(search_view_id, &ToggleFocus, &mut cx);
1612 })
1613 .detach();
1614 deterministic.run_until_parked();
1615 search_view.update(cx, |search_view, cx| {
1616 assert!(
1617 search_view.query_editor.is_focused(cx),
1618 "Empty search view should be focused after the toggle focus event: no results panel to focus on",
1619 );
1620 });
1621
1622 search_view.update(cx, |search_view, cx| {
1623 let query_editor = &search_view.query_editor;
1624 assert!(
1625 query_editor.is_focused(cx),
1626 "Search view should be focused after the new search view is activated",
1627 );
1628 let query_text = query_editor.read(cx).text(cx);
1629 assert!(
1630 query_text.is_empty(),
1631 "New search query should be empty but got '{query_text}'",
1632 );
1633 let results_text = search_view
1634 .results_editor
1635 .update(cx, |editor, cx| editor.display_text(cx));
1636 assert!(
1637 results_text.is_empty(),
1638 "Empty search view should have no results but got '{results_text}'"
1639 );
1640 });
1641
1642 search_view.update(cx, |search_view, cx| {
1643 search_view.query_editor.update(cx, |query_editor, cx| {
1644 query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", cx)
1645 });
1646 search_view.search(cx);
1647 });
1648 deterministic.run_until_parked();
1649 search_view.update(cx, |search_view, cx| {
1650 let results_text = search_view
1651 .results_editor
1652 .update(cx, |editor, cx| editor.display_text(cx));
1653 assert!(
1654 results_text.is_empty(),
1655 "Search view for mismatching query should have no results but got '{results_text}'"
1656 );
1657 assert!(
1658 search_view.query_editor.is_focused(cx),
1659 "Search view should be focused after mismatching query had been used in search",
1660 );
1661 });
1662 cx.spawn(
1663 |mut cx| async move { window.dispatch_action(search_view_id, &ToggleFocus, &mut cx) },
1664 )
1665 .detach();
1666 deterministic.run_until_parked();
1667 search_view.update(cx, |search_view, cx| {
1668 assert!(
1669 search_view.query_editor.is_focused(cx),
1670 "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
1671 );
1672 });
1673
1674 search_view.update(cx, |search_view, cx| {
1675 search_view
1676 .query_editor
1677 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
1678 search_view.search(cx);
1679 });
1680 deterministic.run_until_parked();
1681 search_view.update(cx, |search_view, cx| {
1682 assert_eq!(
1683 search_view
1684 .results_editor
1685 .update(cx, |editor, cx| editor.display_text(cx)),
1686 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
1687 "Search view results should match the query"
1688 );
1689 assert!(
1690 search_view.results_editor.is_focused(cx),
1691 "Search view with mismatching query should be focused after search results are available",
1692 );
1693 });
1694 cx.spawn(|mut cx| async move {
1695 window.dispatch_action(search_view_id, &ToggleFocus, &mut cx);
1696 })
1697 .detach();
1698 deterministic.run_until_parked();
1699 search_view.update(cx, |search_view, cx| {
1700 assert!(
1701 search_view.results_editor.is_focused(cx),
1702 "Search view with matching query should still have its results editor focused after the toggle focus event",
1703 );
1704 });
1705
1706 workspace.update(cx, |workspace, cx| {
1707 ProjectSearchView::deploy(workspace, &workspace::NewSearch, cx)
1708 });
1709 search_view.update(cx, |search_view, cx| {
1710 assert_eq!(search_view.query_editor.read(cx).text(cx), "two", "Query should be updated to first search result after search view 2nd open in a row");
1711 assert_eq!(
1712 search_view
1713 .results_editor
1714 .update(cx, |editor, cx| editor.display_text(cx)),
1715 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
1716 "Results should be unchanged after search view 2nd open in a row"
1717 );
1718 assert!(
1719 search_view.query_editor.is_focused(cx),
1720 "Focus should be moved into query editor again after search view 2nd open in a row"
1721 );
1722 });
1723
1724 cx.spawn(|mut cx| async move {
1725 window.dispatch_action(search_view_id, &ToggleFocus, &mut cx);
1726 })
1727 .detach();
1728 deterministic.run_until_parked();
1729 search_view.update(cx, |search_view, cx| {
1730 assert!(
1731 search_view.results_editor.is_focused(cx),
1732 "Search view with matching query should switch focus to the results editor after the toggle focus event",
1733 );
1734 });
1735 }
1736
1737 #[gpui::test]
1738 async fn test_new_project_search_in_directory(
1739 deterministic: Arc<Deterministic>,
1740 cx: &mut TestAppContext,
1741 ) {
1742 init_test(cx);
1743
1744 let fs = FakeFs::new(cx.background());
1745 fs.insert_tree(
1746 "/dir",
1747 json!({
1748 "a": {
1749 "one.rs": "const ONE: usize = 1;",
1750 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
1751 },
1752 "b": {
1753 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
1754 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
1755 },
1756 }),
1757 )
1758 .await;
1759 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
1760 let worktree_id = project.read_with(cx, |project, cx| {
1761 project.worktrees(cx).next().unwrap().read(cx).id()
1762 });
1763 let workspace = cx
1764 .add_window(|cx| Workspace::test_new(project, cx))
1765 .root(cx);
1766
1767 let active_item = cx.read(|cx| {
1768 workspace
1769 .read(cx)
1770 .active_pane()
1771 .read(cx)
1772 .active_item()
1773 .and_then(|item| item.downcast::<ProjectSearchView>())
1774 });
1775 assert!(
1776 active_item.is_none(),
1777 "Expected no search panel to be active, but got: {active_item:?}"
1778 );
1779
1780 let one_file_entry = cx.update(|cx| {
1781 workspace
1782 .read(cx)
1783 .project()
1784 .read(cx)
1785 .entry_for_path(&(worktree_id, "a/one.rs").into(), cx)
1786 .expect("no entry for /a/one.rs file")
1787 });
1788 assert!(one_file_entry.is_file());
1789 workspace.update(cx, |workspace, cx| {
1790 ProjectSearchView::new_search_in_directory(workspace, &one_file_entry, cx)
1791 });
1792 let active_search_entry = cx.read(|cx| {
1793 workspace
1794 .read(cx)
1795 .active_pane()
1796 .read(cx)
1797 .active_item()
1798 .and_then(|item| item.downcast::<ProjectSearchView>())
1799 });
1800 assert!(
1801 active_search_entry.is_none(),
1802 "Expected no search panel to be active for file entry"
1803 );
1804
1805 let a_dir_entry = cx.update(|cx| {
1806 workspace
1807 .read(cx)
1808 .project()
1809 .read(cx)
1810 .entry_for_path(&(worktree_id, "a").into(), cx)
1811 .expect("no entry for /a/ directory")
1812 });
1813 assert!(a_dir_entry.is_dir());
1814 workspace.update(cx, |workspace, cx| {
1815 ProjectSearchView::new_search_in_directory(workspace, &a_dir_entry, cx)
1816 });
1817
1818 let Some(search_view) = cx.read(|cx| {
1819 workspace
1820 .read(cx)
1821 .active_pane()
1822 .read(cx)
1823 .active_item()
1824 .and_then(|item| item.downcast::<ProjectSearchView>())
1825 }) else {
1826 panic!("Search view expected to appear after new search in directory event trigger")
1827 };
1828 deterministic.run_until_parked();
1829 search_view.update(cx, |search_view, cx| {
1830 assert!(
1831 search_view.query_editor.is_focused(cx),
1832 "On new search in directory, focus should be moved into query editor"
1833 );
1834 search_view.excluded_files_editor.update(cx, |editor, cx| {
1835 assert!(
1836 editor.display_text(cx).is_empty(),
1837 "New search in directory should not have any excluded files"
1838 );
1839 });
1840 search_view.included_files_editor.update(cx, |editor, cx| {
1841 assert_eq!(
1842 editor.display_text(cx),
1843 a_dir_entry.path.to_str().unwrap(),
1844 "New search in directory should have included dir entry path"
1845 );
1846 });
1847 });
1848
1849 search_view.update(cx, |search_view, cx| {
1850 search_view
1851 .query_editor
1852 .update(cx, |query_editor, cx| query_editor.set_text("const", cx));
1853 search_view.search(cx);
1854 });
1855 deterministic.run_until_parked();
1856 search_view.update(cx, |search_view, cx| {
1857 assert_eq!(
1858 search_view
1859 .results_editor
1860 .update(cx, |editor, cx| editor.display_text(cx)),
1861 "\n\nconst ONE: usize = 1;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
1862 "New search in directory should have a filter that matches a certain directory"
1863 );
1864 });
1865 }
1866
1867 #[gpui::test]
1868 async fn test_search_query_history(cx: &mut TestAppContext) {
1869 init_test(cx);
1870
1871 let fs = FakeFs::new(cx.background());
1872 fs.insert_tree(
1873 "/dir",
1874 json!({
1875 "one.rs": "const ONE: usize = 1;",
1876 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
1877 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
1878 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
1879 }),
1880 )
1881 .await;
1882 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
1883 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
1884 let workspace = window.root(cx);
1885 workspace.update(cx, |workspace, cx| {
1886 ProjectSearchView::deploy(workspace, &workspace::NewSearch, cx)
1887 });
1888
1889 let search_view = cx.read(|cx| {
1890 workspace
1891 .read(cx)
1892 .active_pane()
1893 .read(cx)
1894 .active_item()
1895 .and_then(|item| item.downcast::<ProjectSearchView>())
1896 .expect("Search view expected to appear after new search event trigger")
1897 });
1898
1899 let search_bar = window.add_view(cx, |cx| {
1900 let mut search_bar = ProjectSearchBar::new();
1901 search_bar.set_active_pane_item(Some(&search_view), cx);
1902 // search_bar.show(cx);
1903 search_bar
1904 });
1905
1906 // Add 3 search items into the history + another unsubmitted one.
1907 search_view.update(cx, |search_view, cx| {
1908 search_view.search_options = SearchOptions::CASE_SENSITIVE;
1909 search_view
1910 .query_editor
1911 .update(cx, |query_editor, cx| query_editor.set_text("ONE", cx));
1912 search_view.search(cx);
1913 });
1914 cx.foreground().run_until_parked();
1915 search_view.update(cx, |search_view, cx| {
1916 search_view
1917 .query_editor
1918 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
1919 search_view.search(cx);
1920 });
1921 cx.foreground().run_until_parked();
1922 search_view.update(cx, |search_view, cx| {
1923 search_view
1924 .query_editor
1925 .update(cx, |query_editor, cx| query_editor.set_text("THREE", cx));
1926 search_view.search(cx);
1927 });
1928 cx.foreground().run_until_parked();
1929 search_view.update(cx, |search_view, cx| {
1930 search_view.query_editor.update(cx, |query_editor, cx| {
1931 query_editor.set_text("JUST_TEXT_INPUT", cx)
1932 });
1933 });
1934 cx.foreground().run_until_parked();
1935
1936 // Ensure that the latest input with search settings is active.
1937 search_view.update(cx, |search_view, cx| {
1938 assert_eq!(
1939 search_view.query_editor.read(cx).text(cx),
1940 "JUST_TEXT_INPUT"
1941 );
1942 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
1943 });
1944
1945 // Next history query after the latest should set the query to the empty string.
1946 search_bar.update(cx, |search_bar, cx| {
1947 search_bar.next_history_query(&NextHistoryQuery, cx);
1948 });
1949 search_view.update(cx, |search_view, cx| {
1950 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
1951 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
1952 });
1953 search_bar.update(cx, |search_bar, cx| {
1954 search_bar.next_history_query(&NextHistoryQuery, cx);
1955 });
1956 search_view.update(cx, |search_view, cx| {
1957 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
1958 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
1959 });
1960
1961 // First previous query for empty current query should set the query to the latest submitted one.
1962 search_bar.update(cx, |search_bar, cx| {
1963 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
1964 });
1965 search_view.update(cx, |search_view, cx| {
1966 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
1967 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
1968 });
1969
1970 // Further previous items should go over the history in reverse order.
1971 search_bar.update(cx, |search_bar, cx| {
1972 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
1973 });
1974 search_view.update(cx, |search_view, cx| {
1975 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
1976 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
1977 });
1978
1979 // Previous items should never go behind the first history item.
1980 search_bar.update(cx, |search_bar, cx| {
1981 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
1982 });
1983 search_view.update(cx, |search_view, cx| {
1984 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
1985 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
1986 });
1987 search_bar.update(cx, |search_bar, cx| {
1988 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
1989 });
1990 search_view.update(cx, |search_view, cx| {
1991 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
1992 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
1993 });
1994
1995 // Next items should go over the history in the original order.
1996 search_bar.update(cx, |search_bar, cx| {
1997 search_bar.next_history_query(&NextHistoryQuery, cx);
1998 });
1999 search_view.update(cx, |search_view, cx| {
2000 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
2001 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2002 });
2003
2004 search_view.update(cx, |search_view, cx| {
2005 search_view
2006 .query_editor
2007 .update(cx, |query_editor, cx| query_editor.set_text("TWO_NEW", cx));
2008 search_view.search(cx);
2009 });
2010 cx.foreground().run_until_parked();
2011 search_view.update(cx, |search_view, cx| {
2012 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
2013 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2014 });
2015
2016 // New search input should add another entry to history and move the selection to the end of the history.
2017 search_bar.update(cx, |search_bar, cx| {
2018 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2019 });
2020 search_view.update(cx, |search_view, cx| {
2021 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
2022 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2023 });
2024 search_bar.update(cx, |search_bar, cx| {
2025 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2026 });
2027 search_view.update(cx, |search_view, cx| {
2028 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
2029 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2030 });
2031 search_bar.update(cx, |search_bar, cx| {
2032 search_bar.next_history_query(&NextHistoryQuery, cx);
2033 });
2034 search_view.update(cx, |search_view, cx| {
2035 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
2036 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2037 });
2038 search_bar.update(cx, |search_bar, cx| {
2039 search_bar.next_history_query(&NextHistoryQuery, cx);
2040 });
2041 search_view.update(cx, |search_view, cx| {
2042 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
2043 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2044 });
2045 search_bar.update(cx, |search_bar, cx| {
2046 search_bar.next_history_query(&NextHistoryQuery, cx);
2047 });
2048 search_view.update(cx, |search_view, cx| {
2049 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
2050 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2051 });
2052 }
2053
2054 pub fn init_test(cx: &mut TestAppContext) {
2055 cx.foreground().forbid_parking();
2056 let fonts = cx.font_cache();
2057 let mut theme = gpui::fonts::with_font_cache(fonts.clone(), theme::Theme::default);
2058 theme.search.match_background = Color::red();
2059
2060 cx.update(|cx| {
2061 cx.set_global(SettingsStore::test(cx));
2062 cx.set_global(ActiveSearches::default());
2063 settings::register::<SemanticIndexSettings>(cx);
2064
2065 theme::init((), cx);
2066 cx.update_global::<SettingsStore, _, _>(|store, _| {
2067 let mut settings = store.get::<ThemeSettings>(None).clone();
2068 settings.theme = Arc::new(theme);
2069 store.override_global(settings)
2070 });
2071
2072 language::init(cx);
2073 client::init_settings(cx);
2074 editor::init(cx);
2075 workspace::init_settings(cx);
2076 Project::init_settings(cx);
2077 super::init(cx);
2078 });
2079 }
2080}