1use crate::{
2 SearchOptions, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleRegex,
3 ToggleWholeWord,
4};
5use anyhow::Result;
6use collections::HashMap;
7use editor::{
8 items::active_match_index, scroll::autoscroll::Autoscroll, Anchor, Editor, MultiBuffer,
9 SelectAll, MAX_TAB_TITLE_LEN,
10};
11use futures::StreamExt;
12use globset::{Glob, GlobMatcher};
13use gpui::{
14 actions,
15 elements::*,
16 platform::{CursorStyle, MouseButton},
17 Action, AnyElement, AnyViewHandle, AppContext, Entity, ModelContext, ModelHandle, Subscription,
18 Task, View, ViewContext, ViewHandle, WeakModelHandle, WeakViewHandle,
19};
20use menu::Confirm;
21use project::{search::SearchQuery, Project};
22use smallvec::SmallVec;
23use std::{
24 any::{Any, TypeId},
25 borrow::Cow,
26 collections::HashSet,
27 mem,
28 ops::{Not, Range},
29 path::PathBuf,
30 sync::Arc,
31};
32use util::ResultExt as _;
33use workspace::{
34 item::{BreadcrumbText, Item, ItemEvent, ItemHandle},
35 searchable::{Direction, SearchableItem, SearchableItemHandle},
36 ItemNavHistory, Pane, ToolbarItemLocation, ToolbarItemView, Workspace, WorkspaceId,
37};
38
39actions!(project_search, [SearchInNew, ToggleFocus, NextField]);
40
41#[derive(Default)]
42struct ActiveSearches(HashMap<WeakModelHandle<Project>, WeakViewHandle<ProjectSearchView>>);
43
44pub fn init(cx: &mut AppContext) {
45 cx.set_global(ActiveSearches::default());
46 cx.add_action(ProjectSearchView::deploy);
47 cx.add_action(ProjectSearchView::move_focus_to_results);
48 cx.add_action(ProjectSearchBar::search);
49 cx.add_action(ProjectSearchBar::search_in_new);
50 cx.add_action(ProjectSearchBar::select_next_match);
51 cx.add_action(ProjectSearchBar::select_prev_match);
52 cx.capture_action(ProjectSearchBar::tab);
53 cx.capture_action(ProjectSearchBar::tab_previous);
54 add_toggle_option_action::<ToggleCaseSensitive>(SearchOptions::CASE_SENSITIVE, cx);
55 add_toggle_option_action::<ToggleWholeWord>(SearchOptions::WHOLE_WORD, cx);
56 add_toggle_option_action::<ToggleRegex>(SearchOptions::REGEX, cx);
57}
58
59fn add_toggle_option_action<A: Action>(option: SearchOptions, cx: &mut AppContext) {
60 cx.add_action(move |pane: &mut Pane, _: &A, cx: &mut ViewContext<Pane>| {
61 if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<ProjectSearchBar>() {
62 if search_bar.update(cx, |search_bar, cx| {
63 search_bar.toggle_search_option(option, cx)
64 }) {
65 return;
66 }
67 }
68 cx.propagate_action();
69 });
70}
71
72struct ProjectSearch {
73 project: ModelHandle<Project>,
74 excerpts: ModelHandle<MultiBuffer>,
75 pending_search: Option<Task<Option<()>>>,
76 match_ranges: Vec<Range<Anchor>>,
77 active_query: Option<SearchQuery>,
78 search_id: usize,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
82enum InputPanel {
83 Query,
84 Exclude,
85 Include,
86}
87
88pub struct ProjectSearchView {
89 model: ModelHandle<ProjectSearch>,
90 query_editor: ViewHandle<Editor>,
91 results_editor: ViewHandle<Editor>,
92 search_options: SearchOptions,
93 panels_with_errors: HashSet<InputPanel>,
94 active_match_index: Option<usize>,
95 search_id: usize,
96 query_editor_was_focused: bool,
97 included_files_editor: ViewHandle<Editor>,
98 excluded_files_editor: ViewHandle<Editor>,
99}
100
101pub struct ProjectSearchBar {
102 active_project_search: Option<ViewHandle<ProjectSearchView>>,
103 subscription: Option<Subscription>,
104}
105
106impl Entity for ProjectSearch {
107 type Event = ();
108}
109
110impl ProjectSearch {
111 fn new(project: ModelHandle<Project>, cx: &mut ModelContext<Self>) -> Self {
112 let replica_id = project.read(cx).replica_id();
113 Self {
114 project,
115 excerpts: cx.add_model(|_| MultiBuffer::new(replica_id)),
116 pending_search: Default::default(),
117 match_ranges: Default::default(),
118 active_query: None,
119 search_id: 0,
120 }
121 }
122
123 fn clone(&self, cx: &mut ModelContext<Self>) -> ModelHandle<Self> {
124 cx.add_model(|cx| Self {
125 project: self.project.clone(),
126 excerpts: self
127 .excerpts
128 .update(cx, |excerpts, cx| cx.add_model(|cx| excerpts.clone(cx))),
129 pending_search: Default::default(),
130 match_ranges: self.match_ranges.clone(),
131 active_query: self.active_query.clone(),
132 search_id: self.search_id,
133 })
134 }
135
136 fn search(&mut self, query: SearchQuery, cx: &mut ModelContext<Self>) {
137 let search = self
138 .project
139 .update(cx, |project, cx| project.search(query.clone(), cx));
140 self.search_id += 1;
141 self.active_query = Some(query);
142 self.match_ranges.clear();
143 self.pending_search = Some(cx.spawn_weak(|this, mut cx| async move {
144 let matches = search.await.log_err()?;
145 let this = this.upgrade(&cx)?;
146 let mut matches = matches.into_iter().collect::<Vec<_>>();
147 let (_task, mut match_ranges) = this.update(&mut cx, |this, cx| {
148 this.match_ranges.clear();
149 matches.sort_by_key(|(buffer, _)| buffer.read(cx).file().map(|file| file.path()));
150 this.excerpts.update(cx, |excerpts, cx| {
151 excerpts.clear(cx);
152 excerpts.stream_excerpts_with_context_lines(matches, 1, cx)
153 })
154 });
155
156 while let Some(match_range) = match_ranges.next().await {
157 this.update(&mut cx, |this, cx| {
158 this.match_ranges.push(match_range);
159 while let Ok(Some(match_range)) = match_ranges.try_next() {
160 this.match_ranges.push(match_range);
161 }
162 cx.notify();
163 });
164 }
165
166 this.update(&mut cx, |this, cx| {
167 this.pending_search.take();
168 cx.notify();
169 });
170
171 None
172 }));
173 cx.notify();
174 }
175}
176
177pub enum ViewEvent {
178 UpdateTab,
179 Activate,
180 EditorEvent(editor::Event),
181}
182
183impl Entity for ProjectSearchView {
184 type Event = ViewEvent;
185}
186
187impl View for ProjectSearchView {
188 fn ui_name() -> &'static str {
189 "ProjectSearchView"
190 }
191
192 fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
193 let model = &self.model.read(cx);
194 if model.match_ranges.is_empty() {
195 enum Status {}
196
197 let theme = theme::current(cx).clone();
198 let text = if self.query_editor.read(cx).text(cx).is_empty() {
199 ""
200 } else if model.pending_search.is_some() {
201 "Searching..."
202 } else {
203 "No results"
204 };
205 MouseEventHandler::<Status, _>::new(0, cx, |_, _| {
206 Label::new(text, theme.search.results_status.clone())
207 .aligned()
208 .contained()
209 .with_background_color(theme.editor.background)
210 .flex(1., true)
211 })
212 .on_down(MouseButton::Left, |_, _, cx| {
213 cx.focus_parent();
214 })
215 .into_any_named("project search view")
216 } else {
217 ChildView::new(&self.results_editor, cx)
218 .flex(1., true)
219 .into_any_named("project search view")
220 }
221 }
222
223 fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
224 let handle = cx.weak_handle();
225 cx.update_global(|state: &mut ActiveSearches, cx| {
226 state
227 .0
228 .insert(self.model.read(cx).project.downgrade(), handle)
229 });
230
231 if cx.is_self_focused() {
232 if self.query_editor_was_focused {
233 cx.focus(&self.query_editor);
234 } else {
235 cx.focus(&self.results_editor);
236 }
237 }
238 }
239}
240
241impl Item for ProjectSearchView {
242 fn tab_tooltip_text(&self, cx: &AppContext) -> Option<Cow<str>> {
243 let query_text = self.query_editor.read(cx).text(cx);
244
245 query_text
246 .is_empty()
247 .not()
248 .then(|| query_text.into())
249 .or_else(|| Some("Project Search".into()))
250 }
251
252 fn act_as_type<'a>(
253 &'a self,
254 type_id: TypeId,
255 self_handle: &'a ViewHandle<Self>,
256 _: &'a AppContext,
257 ) -> Option<&'a AnyViewHandle> {
258 if type_id == TypeId::of::<Self>() {
259 Some(self_handle)
260 } else if type_id == TypeId::of::<Editor>() {
261 Some(&self.results_editor)
262 } else {
263 None
264 }
265 }
266
267 fn deactivated(&mut self, cx: &mut ViewContext<Self>) {
268 self.results_editor
269 .update(cx, |editor, cx| editor.deactivated(cx));
270 }
271
272 fn tab_content<T: View>(
273 &self,
274 _detail: Option<usize>,
275 tab_theme: &theme::Tab,
276 cx: &AppContext,
277 ) -> AnyElement<T> {
278 Flex::row()
279 .with_child(
280 Svg::new("icons/magnifying_glass_12.svg")
281 .with_color(tab_theme.label.text.color)
282 .constrained()
283 .with_width(tab_theme.type_icon_width)
284 .aligned()
285 .contained()
286 .with_margin_right(tab_theme.spacing),
287 )
288 .with_children(self.model.read(cx).active_query.as_ref().map(|query| {
289 let query_text = util::truncate_and_trailoff(query.as_str(), MAX_TAB_TITLE_LEN);
290
291 Label::new(query_text, tab_theme.label.clone()).aligned()
292 }))
293 .into_any()
294 }
295
296 fn for_each_project_item(&self, cx: &AppContext, f: &mut dyn FnMut(usize, &dyn project::Item)) {
297 self.results_editor.for_each_project_item(cx, f)
298 }
299
300 fn is_singleton(&self, _: &AppContext) -> bool {
301 false
302 }
303
304 fn can_save(&self, _: &AppContext) -> bool {
305 true
306 }
307
308 fn is_dirty(&self, cx: &AppContext) -> bool {
309 self.results_editor.read(cx).is_dirty(cx)
310 }
311
312 fn has_conflict(&self, cx: &AppContext) -> bool {
313 self.results_editor.read(cx).has_conflict(cx)
314 }
315
316 fn save(
317 &mut self,
318 project: ModelHandle<Project>,
319 cx: &mut ViewContext<Self>,
320 ) -> Task<anyhow::Result<()>> {
321 self.results_editor
322 .update(cx, |editor, cx| editor.save(project, cx))
323 }
324
325 fn save_as(
326 &mut self,
327 _: ModelHandle<Project>,
328 _: PathBuf,
329 _: &mut ViewContext<Self>,
330 ) -> Task<anyhow::Result<()>> {
331 unreachable!("save_as should not have been called")
332 }
333
334 fn reload(
335 &mut self,
336 project: ModelHandle<Project>,
337 cx: &mut ViewContext<Self>,
338 ) -> Task<anyhow::Result<()>> {
339 self.results_editor
340 .update(cx, |editor, cx| editor.reload(project, cx))
341 }
342
343 fn clone_on_split(&self, _workspace_id: WorkspaceId, cx: &mut ViewContext<Self>) -> Option<Self>
344 where
345 Self: Sized,
346 {
347 let model = self.model.update(cx, |model, cx| model.clone(cx));
348 Some(Self::new(model, cx))
349 }
350
351 fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext<Self>) {
352 self.results_editor
353 .update(cx, |editor, cx| editor.added_to_workspace(workspace, cx));
354 }
355
356 fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut ViewContext<Self>) {
357 self.results_editor.update(cx, |editor, _| {
358 editor.set_nav_history(Some(nav_history));
359 });
360 }
361
362 fn navigate(&mut self, data: Box<dyn Any>, cx: &mut ViewContext<Self>) -> bool {
363 self.results_editor
364 .update(cx, |editor, cx| editor.navigate(data, cx))
365 }
366
367 fn to_item_events(event: &Self::Event) -> SmallVec<[ItemEvent; 2]> {
368 match event {
369 ViewEvent::UpdateTab => {
370 smallvec::smallvec![ItemEvent::UpdateBreadcrumbs, ItemEvent::UpdateTab]
371 }
372 ViewEvent::EditorEvent(editor_event) => Editor::to_item_events(editor_event),
373 _ => SmallVec::new(),
374 }
375 }
376
377 fn breadcrumb_location(&self) -> ToolbarItemLocation {
378 if self.has_matches() {
379 ToolbarItemLocation::Secondary
380 } else {
381 ToolbarItemLocation::Hidden
382 }
383 }
384
385 fn breadcrumbs(&self, theme: &theme::Theme, cx: &AppContext) -> Option<Vec<BreadcrumbText>> {
386 self.results_editor.breadcrumbs(theme, cx)
387 }
388
389 fn serialized_item_kind() -> Option<&'static str> {
390 None
391 }
392
393 fn deserialize(
394 _project: ModelHandle<Project>,
395 _workspace: WeakViewHandle<Workspace>,
396 _workspace_id: workspace::WorkspaceId,
397 _item_id: workspace::ItemId,
398 _cx: &mut ViewContext<Pane>,
399 ) -> Task<anyhow::Result<ViewHandle<Self>>> {
400 unimplemented!()
401 }
402}
403
404impl ProjectSearchView {
405 fn new(model: ModelHandle<ProjectSearch>, cx: &mut ViewContext<Self>) -> Self {
406 let project;
407 let excerpts;
408 let mut query_text = String::new();
409 let mut options = SearchOptions::NONE;
410
411 {
412 let model = model.read(cx);
413 project = model.project.clone();
414 excerpts = model.excerpts.clone();
415 if let Some(active_query) = model.active_query.as_ref() {
416 query_text = active_query.as_str().to_string();
417 options = SearchOptions::from_query(active_query);
418 }
419 }
420 cx.observe(&model, |this, _, cx| this.model_changed(cx))
421 .detach();
422
423 let query_editor = cx.add_view(|cx| {
424 let mut editor = Editor::single_line(
425 Some(Arc::new(|theme| theme.search.editor.input.clone())),
426 cx,
427 );
428 editor.set_text(query_text, cx);
429 editor
430 });
431 // Subscribe to query_editor in order to reraise editor events for workspace item activation purposes
432 cx.subscribe(&query_editor, |_, _, event, cx| {
433 cx.emit(ViewEvent::EditorEvent(event.clone()))
434 })
435 .detach();
436
437 let results_editor = cx.add_view(|cx| {
438 let mut editor = Editor::for_multibuffer(excerpts, Some(project), cx);
439 editor.set_searchable(false);
440 editor
441 });
442 cx.observe(&results_editor, |_, _, cx| cx.emit(ViewEvent::UpdateTab))
443 .detach();
444
445 cx.subscribe(&results_editor, |this, _, event, cx| {
446 if matches!(event, editor::Event::SelectionsChanged { .. }) {
447 this.update_match_index(cx);
448 }
449 // Reraise editor events for workspace item activation purposes
450 cx.emit(ViewEvent::EditorEvent(event.clone()));
451 })
452 .detach();
453
454 let included_files_editor = cx.add_view(|cx| {
455 let mut editor = Editor::single_line(
456 Some(Arc::new(|theme| {
457 theme.search.include_exclude_editor.input.clone()
458 })),
459 cx,
460 );
461 editor.set_placeholder_text("Include: crates/**/*.toml", cx);
462
463 editor
464 });
465 // Subscribe to include_files_editor in order to reraise editor events for workspace item activation purposes
466 cx.subscribe(&included_files_editor, |_, _, event, cx| {
467 cx.emit(ViewEvent::EditorEvent(event.clone()))
468 })
469 .detach();
470
471 let excluded_files_editor = cx.add_view(|cx| {
472 let mut editor = Editor::single_line(
473 Some(Arc::new(|theme| {
474 theme.search.include_exclude_editor.input.clone()
475 })),
476 cx,
477 );
478 editor.set_placeholder_text("Exclude: vendor/*, *.lock", cx);
479
480 editor
481 });
482 // Subscribe to excluded_files_editor in order to reraise editor events for workspace item activation purposes
483 cx.subscribe(&excluded_files_editor, |_, _, event, cx| {
484 cx.emit(ViewEvent::EditorEvent(event.clone()))
485 })
486 .detach();
487
488 let mut this = ProjectSearchView {
489 search_id: model.read(cx).search_id,
490 model,
491 query_editor,
492 results_editor,
493 search_options: options,
494 panels_with_errors: HashSet::new(),
495 active_match_index: None,
496 query_editor_was_focused: false,
497 included_files_editor,
498 excluded_files_editor,
499 };
500 this.model_changed(cx);
501 this
502 }
503
504 // Re-activate the most recently activated search or the most recent if it has been closed.
505 // If no search exists in the workspace, create a new one.
506 fn deploy(
507 workspace: &mut Workspace,
508 _: &workspace::NewSearch,
509 cx: &mut ViewContext<Workspace>,
510 ) {
511 // Clean up entries for dropped projects
512 cx.update_global(|state: &mut ActiveSearches, cx| {
513 state.0.retain(|project, _| project.is_upgradable(cx))
514 });
515
516 let active_search = cx
517 .global::<ActiveSearches>()
518 .0
519 .get(&workspace.project().downgrade());
520
521 let existing = active_search
522 .and_then(|active_search| {
523 workspace
524 .items_of_type::<ProjectSearchView>(cx)
525 .find(|search| search == active_search)
526 })
527 .or_else(|| workspace.item_of_type::<ProjectSearchView>(cx));
528
529 let query = workspace.active_item(cx).and_then(|item| {
530 let editor = item.act_as::<Editor>(cx)?;
531 let query = editor.query_suggestion(cx);
532 if query.is_empty() {
533 None
534 } else {
535 Some(query)
536 }
537 });
538
539 let search = if let Some(existing) = existing {
540 workspace.activate_item(&existing, cx);
541 existing
542 } else {
543 let model = cx.add_model(|cx| ProjectSearch::new(workspace.project().clone(), cx));
544 let view = cx.add_view(|cx| ProjectSearchView::new(model, cx));
545 workspace.add_item(Box::new(view.clone()), cx);
546 view
547 };
548
549 search.update(cx, |search, cx| {
550 if let Some(query) = query {
551 search.set_query(&query, cx);
552 }
553 search.focus_query_editor(cx)
554 });
555 }
556
557 fn search(&mut self, cx: &mut ViewContext<Self>) {
558 if let Some(query) = self.build_search_query(cx) {
559 self.model.update(cx, |model, cx| model.search(query, cx));
560 }
561 }
562
563 fn build_search_query(&mut self, cx: &mut ViewContext<Self>) -> Option<SearchQuery> {
564 let text = self.query_editor.read(cx).text(cx);
565 let included_files =
566 match Self::load_glob_set(&self.included_files_editor.read(cx).text(cx)) {
567 Ok(included_files) => {
568 self.panels_with_errors.remove(&InputPanel::Include);
569 included_files
570 }
571 Err(_e) => {
572 self.panels_with_errors.insert(InputPanel::Include);
573 cx.notify();
574 return None;
575 }
576 };
577 let excluded_files =
578 match Self::load_glob_set(&self.excluded_files_editor.read(cx).text(cx)) {
579 Ok(excluded_files) => {
580 self.panels_with_errors.remove(&InputPanel::Exclude);
581 excluded_files
582 }
583 Err(_e) => {
584 self.panels_with_errors.insert(InputPanel::Exclude);
585 cx.notify();
586 return None;
587 }
588 };
589 if self.search_options.contains(SearchOptions::REGEX) {
590 match SearchQuery::regex(
591 text,
592 self.search_options.contains(SearchOptions::WHOLE_WORD),
593 self.search_options.contains(SearchOptions::CASE_SENSITIVE),
594 included_files,
595 excluded_files,
596 ) {
597 Ok(query) => {
598 self.panels_with_errors.remove(&InputPanel::Query);
599 Some(query)
600 }
601 Err(_e) => {
602 self.panels_with_errors.insert(InputPanel::Query);
603 cx.notify();
604 None
605 }
606 }
607 } else {
608 Some(SearchQuery::text(
609 text,
610 self.search_options.contains(SearchOptions::WHOLE_WORD),
611 self.search_options.contains(SearchOptions::CASE_SENSITIVE),
612 included_files,
613 excluded_files,
614 ))
615 }
616 }
617
618 fn load_glob_set(text: &str) -> Result<Vec<GlobMatcher>> {
619 text.split(',')
620 .map(str::trim)
621 .filter(|glob_str| !glob_str.is_empty())
622 .map(|glob_str| anyhow::Ok(Glob::new(glob_str)?.compile_matcher()))
623 .collect()
624 }
625
626 fn select_match(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
627 if let Some(index) = self.active_match_index {
628 let match_ranges = self.model.read(cx).match_ranges.clone();
629 let new_index = self.results_editor.update(cx, |editor, cx| {
630 editor.match_index_for_direction(&match_ranges, index, direction, cx)
631 });
632
633 let range_to_select = match_ranges[new_index].clone();
634 self.results_editor.update(cx, |editor, cx| {
635 editor.unfold_ranges([range_to_select.clone()], false, true, cx);
636 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
637 s.select_ranges([range_to_select])
638 });
639 });
640 }
641 }
642
643 fn focus_query_editor(&mut self, cx: &mut ViewContext<Self>) {
644 self.query_editor.update(cx, |query_editor, cx| {
645 query_editor.select_all(&SelectAll, cx);
646 });
647 self.query_editor_was_focused = true;
648 cx.focus(&self.query_editor);
649 }
650
651 fn set_query(&mut self, query: &str, cx: &mut ViewContext<Self>) {
652 self.query_editor
653 .update(cx, |query_editor, cx| query_editor.set_text(query, cx));
654 }
655
656 fn focus_results_editor(&mut self, cx: &mut ViewContext<Self>) {
657 self.query_editor.update(cx, |query_editor, cx| {
658 let cursor = query_editor.selections.newest_anchor().head();
659 query_editor.change_selections(None, cx, |s| s.select_ranges([cursor.clone()..cursor]));
660 });
661 self.query_editor_was_focused = false;
662 cx.focus(&self.results_editor);
663 }
664
665 fn model_changed(&mut self, cx: &mut ViewContext<Self>) {
666 let match_ranges = self.model.read(cx).match_ranges.clone();
667 if match_ranges.is_empty() {
668 self.active_match_index = None;
669 } else {
670 let prev_search_id = mem::replace(&mut self.search_id, self.model.read(cx).search_id);
671 let is_new_search = self.search_id != prev_search_id;
672 self.results_editor.update(cx, |editor, cx| {
673 if is_new_search {
674 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
675 s.select_ranges(match_ranges.first().cloned())
676 });
677 }
678 editor.highlight_background::<Self>(
679 match_ranges,
680 |theme| theme.search.match_background,
681 cx,
682 );
683 });
684 if is_new_search && self.query_editor.is_focused(cx) {
685 self.focus_results_editor(cx);
686 }
687 }
688
689 cx.emit(ViewEvent::UpdateTab);
690 cx.notify();
691 }
692
693 fn update_match_index(&mut self, cx: &mut ViewContext<Self>) {
694 let results_editor = self.results_editor.read(cx);
695 let new_index = active_match_index(
696 &self.model.read(cx).match_ranges,
697 &results_editor.selections.newest_anchor().head(),
698 &results_editor.buffer().read(cx).snapshot(cx),
699 );
700 if self.active_match_index != new_index {
701 self.active_match_index = new_index;
702 cx.notify();
703 }
704 }
705
706 pub fn has_matches(&self) -> bool {
707 self.active_match_index.is_some()
708 }
709
710 fn move_focus_to_results(pane: &mut Pane, _: &ToggleFocus, cx: &mut ViewContext<Pane>) {
711 if let Some(search_view) = pane
712 .active_item()
713 .and_then(|item| item.downcast::<ProjectSearchView>())
714 {
715 search_view.update(cx, |search_view, cx| {
716 if !search_view.results_editor.is_focused(cx)
717 && !search_view.model.read(cx).match_ranges.is_empty()
718 {
719 return search_view.focus_results_editor(cx);
720 }
721 });
722 }
723
724 cx.propagate_action();
725 }
726}
727
728impl Default for ProjectSearchBar {
729 fn default() -> Self {
730 Self::new()
731 }
732}
733
734impl ProjectSearchBar {
735 pub fn new() -> Self {
736 Self {
737 active_project_search: Default::default(),
738 subscription: Default::default(),
739 }
740 }
741
742 fn search(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
743 if let Some(search_view) = self.active_project_search.as_ref() {
744 search_view.update(cx, |search_view, cx| search_view.search(cx));
745 }
746 }
747
748 fn search_in_new(workspace: &mut Workspace, _: &SearchInNew, cx: &mut ViewContext<Workspace>) {
749 if let Some(search_view) = workspace
750 .active_item(cx)
751 .and_then(|item| item.downcast::<ProjectSearchView>())
752 {
753 let new_query = search_view.update(cx, |search_view, cx| {
754 let new_query = search_view.build_search_query(cx);
755 if new_query.is_some() {
756 if let Some(old_query) = search_view.model.read(cx).active_query.clone() {
757 search_view.query_editor.update(cx, |editor, cx| {
758 editor.set_text(old_query.as_str(), cx);
759 });
760 search_view.search_options = SearchOptions::from_query(&old_query);
761 }
762 }
763 new_query
764 });
765 if let Some(new_query) = new_query {
766 let model = cx.add_model(|cx| {
767 let mut model = ProjectSearch::new(workspace.project().clone(), cx);
768 model.search(new_query, cx);
769 model
770 });
771 workspace.add_item(
772 Box::new(cx.add_view(|cx| ProjectSearchView::new(model, cx))),
773 cx,
774 );
775 }
776 }
777 }
778
779 fn select_next_match(pane: &mut Pane, _: &SelectNextMatch, cx: &mut ViewContext<Pane>) {
780 if let Some(search_view) = pane
781 .active_item()
782 .and_then(|item| item.downcast::<ProjectSearchView>())
783 {
784 search_view.update(cx, |view, cx| view.select_match(Direction::Next, cx));
785 } else {
786 cx.propagate_action();
787 }
788 }
789
790 fn select_prev_match(pane: &mut Pane, _: &SelectPrevMatch, cx: &mut ViewContext<Pane>) {
791 if let Some(search_view) = pane
792 .active_item()
793 .and_then(|item| item.downcast::<ProjectSearchView>())
794 {
795 search_view.update(cx, |view, cx| view.select_match(Direction::Prev, cx));
796 } else {
797 cx.propagate_action();
798 }
799 }
800
801 fn tab(&mut self, _: &editor::Tab, cx: &mut ViewContext<Self>) {
802 self.cycle_field(Direction::Next, cx);
803 }
804
805 fn tab_previous(&mut self, _: &editor::TabPrev, cx: &mut ViewContext<Self>) {
806 self.cycle_field(Direction::Prev, cx);
807 }
808
809 fn cycle_field(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
810 let active_project_search = match &self.active_project_search {
811 Some(active_project_search) => active_project_search,
812
813 None => {
814 cx.propagate_action();
815 return;
816 }
817 };
818
819 active_project_search.update(cx, |project_view, cx| {
820 let views = &[
821 &project_view.query_editor,
822 &project_view.included_files_editor,
823 &project_view.excluded_files_editor,
824 ];
825
826 let current_index = match views
827 .iter()
828 .enumerate()
829 .find(|(_, view)| view.is_focused(cx))
830 {
831 Some((index, _)) => index,
832
833 None => {
834 cx.propagate_action();
835 return;
836 }
837 };
838
839 let new_index = match direction {
840 Direction::Next => (current_index + 1) % views.len(),
841 Direction::Prev if current_index == 0 => views.len() - 1,
842 Direction::Prev => (current_index - 1) % views.len(),
843 };
844 cx.focus(views[new_index]);
845 });
846 }
847
848 fn toggle_search_option(&mut self, option: SearchOptions, cx: &mut ViewContext<Self>) -> bool {
849 if let Some(search_view) = self.active_project_search.as_ref() {
850 search_view.update(cx, |search_view, cx| {
851 search_view.search_options.toggle(option);
852 search_view.search(cx);
853 });
854 cx.notify();
855 true
856 } else {
857 false
858 }
859 }
860
861 fn render_nav_button(
862 &self,
863 icon: &'static str,
864 direction: Direction,
865 cx: &mut ViewContext<Self>,
866 ) -> AnyElement<Self> {
867 let action: Box<dyn Action>;
868 let tooltip;
869 match direction {
870 Direction::Prev => {
871 action = Box::new(SelectPrevMatch);
872 tooltip = "Select Previous Match";
873 }
874 Direction::Next => {
875 action = Box::new(SelectNextMatch);
876 tooltip = "Select Next Match";
877 }
878 };
879 let tooltip_style = theme::current(cx).tooltip.clone();
880
881 enum NavButton {}
882 MouseEventHandler::<NavButton, _>::new(direction as usize, cx, |state, cx| {
883 let theme = theme::current(cx);
884 let style = theme.search.option_button.inactive_state().style_for(state);
885 Label::new(icon, style.text.clone())
886 .contained()
887 .with_style(style.container)
888 })
889 .on_click(MouseButton::Left, move |_, this, cx| {
890 if let Some(search) = this.active_project_search.as_ref() {
891 search.update(cx, |search, cx| search.select_match(direction, cx));
892 }
893 })
894 .with_cursor_style(CursorStyle::PointingHand)
895 .with_tooltip::<NavButton>(
896 direction as usize,
897 tooltip.to_string(),
898 Some(action),
899 tooltip_style,
900 cx,
901 )
902 .into_any()
903 }
904
905 fn render_option_button(
906 &self,
907 icon: &'static str,
908 option: SearchOptions,
909 cx: &mut ViewContext<Self>,
910 ) -> AnyElement<Self> {
911 let tooltip_style = theme::current(cx).tooltip.clone();
912 let is_active = self.is_option_enabled(option, cx);
913 MouseEventHandler::<Self, _>::new(option.bits as usize, cx, |state, cx| {
914 let theme = theme::current(cx);
915 let style = theme
916 .search
917 .option_button
918 .in_state(is_active)
919 .style_for(state);
920 Label::new(icon, style.text.clone())
921 .contained()
922 .with_style(style.container)
923 })
924 .on_click(MouseButton::Left, move |_, this, cx| {
925 this.toggle_search_option(option, cx);
926 })
927 .with_cursor_style(CursorStyle::PointingHand)
928 .with_tooltip::<Self>(
929 option.bits as usize,
930 format!("Toggle {}", option.label()),
931 Some(option.to_toggle_action()),
932 tooltip_style,
933 cx,
934 )
935 .into_any()
936 }
937
938 fn is_option_enabled(&self, option: SearchOptions, cx: &AppContext) -> bool {
939 if let Some(search) = self.active_project_search.as_ref() {
940 search.read(cx).search_options.contains(option)
941 } else {
942 false
943 }
944 }
945}
946
947impl Entity for ProjectSearchBar {
948 type Event = ();
949}
950
951impl View for ProjectSearchBar {
952 fn ui_name() -> &'static str {
953 "ProjectSearchBar"
954 }
955
956 fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
957 if let Some(search) = self.active_project_search.as_ref() {
958 let search = search.read(cx);
959 let theme = theme::current(cx).clone();
960 let query_container_style = if search.panels_with_errors.contains(&InputPanel::Query) {
961 theme.search.invalid_editor
962 } else {
963 theme.search.editor.input.container
964 };
965 let include_container_style =
966 if search.panels_with_errors.contains(&InputPanel::Include) {
967 theme.search.invalid_include_exclude_editor
968 } else {
969 theme.search.include_exclude_editor.input.container
970 };
971 let exclude_container_style =
972 if search.panels_with_errors.contains(&InputPanel::Exclude) {
973 theme.search.invalid_include_exclude_editor
974 } else {
975 theme.search.include_exclude_editor.input.container
976 };
977
978 let included_files_view = ChildView::new(&search.included_files_editor, cx)
979 .aligned()
980 .left()
981 .flex(1.0, true);
982 let excluded_files_view = ChildView::new(&search.excluded_files_editor, cx)
983 .aligned()
984 .right()
985 .flex(1.0, true);
986
987 let row_spacing = theme.workspace.toolbar.container.padding.bottom;
988
989 Flex::column()
990 .with_child(
991 Flex::row()
992 .with_child(
993 Flex::row()
994 .with_child(
995 ChildView::new(&search.query_editor, cx)
996 .aligned()
997 .left()
998 .flex(1., true),
999 )
1000 .with_children(search.active_match_index.map(|match_ix| {
1001 Label::new(
1002 format!(
1003 "{}/{}",
1004 match_ix + 1,
1005 search.model.read(cx).match_ranges.len()
1006 ),
1007 theme.search.match_index.text.clone(),
1008 )
1009 .contained()
1010 .with_style(theme.search.match_index.container)
1011 .aligned()
1012 }))
1013 .contained()
1014 .with_style(query_container_style)
1015 .aligned()
1016 .constrained()
1017 .with_min_width(theme.search.editor.min_width)
1018 .with_max_width(theme.search.editor.max_width)
1019 .flex(1., false),
1020 )
1021 .with_child(
1022 Flex::row()
1023 .with_child(self.render_nav_button("<", Direction::Prev, cx))
1024 .with_child(self.render_nav_button(">", Direction::Next, cx))
1025 .aligned(),
1026 )
1027 .with_child(
1028 Flex::row()
1029 .with_child(self.render_option_button(
1030 "Case",
1031 SearchOptions::CASE_SENSITIVE,
1032 cx,
1033 ))
1034 .with_child(self.render_option_button(
1035 "Word",
1036 SearchOptions::WHOLE_WORD,
1037 cx,
1038 ))
1039 .with_child(self.render_option_button(
1040 "Regex",
1041 SearchOptions::REGEX,
1042 cx,
1043 ))
1044 .contained()
1045 .with_style(theme.search.option_button_group)
1046 .aligned(),
1047 )
1048 .contained()
1049 .with_margin_bottom(row_spacing),
1050 )
1051 .with_child(
1052 Flex::row()
1053 .with_child(
1054 Flex::row()
1055 .with_child(included_files_view)
1056 .contained()
1057 .with_style(include_container_style)
1058 .aligned()
1059 .constrained()
1060 .with_min_width(theme.search.include_exclude_editor.min_width)
1061 .with_max_width(theme.search.include_exclude_editor.max_width)
1062 .flex(1., false),
1063 )
1064 .with_child(
1065 Flex::row()
1066 .with_child(excluded_files_view)
1067 .contained()
1068 .with_style(exclude_container_style)
1069 .aligned()
1070 .constrained()
1071 .with_min_width(theme.search.include_exclude_editor.min_width)
1072 .with_max_width(theme.search.include_exclude_editor.max_width)
1073 .flex(1., false),
1074 ),
1075 )
1076 .contained()
1077 .with_style(theme.search.container)
1078 .aligned()
1079 .left()
1080 .into_any_named("project search")
1081 } else {
1082 Empty::new().into_any()
1083 }
1084 }
1085}
1086
1087impl ToolbarItemView for ProjectSearchBar {
1088 fn set_active_pane_item(
1089 &mut self,
1090 active_pane_item: Option<&dyn ItemHandle>,
1091 cx: &mut ViewContext<Self>,
1092 ) -> ToolbarItemLocation {
1093 cx.notify();
1094 self.subscription = None;
1095 self.active_project_search = None;
1096 if let Some(search) = active_pane_item.and_then(|i| i.downcast::<ProjectSearchView>()) {
1097 self.subscription = Some(cx.observe(&search, |_, _, cx| cx.notify()));
1098 self.active_project_search = Some(search);
1099 ToolbarItemLocation::PrimaryLeft {
1100 flex: Some((1., false)),
1101 }
1102 } else {
1103 ToolbarItemLocation::Hidden
1104 }
1105 }
1106
1107 fn row_count(&self) -> usize {
1108 2
1109 }
1110}
1111
1112#[cfg(test)]
1113pub mod tests {
1114 use super::*;
1115 use editor::DisplayPoint;
1116 use gpui::{color::Color, executor::Deterministic, TestAppContext};
1117 use project::FakeFs;
1118 use serde_json::json;
1119 use settings::SettingsStore;
1120 use std::sync::Arc;
1121 use theme::ThemeSettings;
1122
1123 #[gpui::test]
1124 async fn test_project_search(deterministic: Arc<Deterministic>, cx: &mut TestAppContext) {
1125 init_test(cx);
1126
1127 let fs = FakeFs::new(cx.background());
1128 fs.insert_tree(
1129 "/dir",
1130 json!({
1131 "one.rs": "const ONE: usize = 1;",
1132 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
1133 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
1134 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
1135 }),
1136 )
1137 .await;
1138 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
1139 let search = cx.add_model(|cx| ProjectSearch::new(project, cx));
1140 let (_, search_view) = cx.add_window(|cx| ProjectSearchView::new(search.clone(), cx));
1141
1142 search_view.update(cx, |search_view, cx| {
1143 search_view
1144 .query_editor
1145 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
1146 search_view.search(cx);
1147 });
1148 deterministic.run_until_parked();
1149 search_view.update(cx, |search_view, cx| {
1150 assert_eq!(
1151 search_view
1152 .results_editor
1153 .update(cx, |editor, cx| editor.display_text(cx)),
1154 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;"
1155 );
1156 assert_eq!(
1157 search_view
1158 .results_editor
1159 .update(cx, |editor, cx| editor.all_background_highlights(cx)),
1160 &[
1161 (
1162 DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35),
1163 Color::red()
1164 ),
1165 (
1166 DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40),
1167 Color::red()
1168 ),
1169 (
1170 DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9),
1171 Color::red()
1172 )
1173 ]
1174 );
1175 assert_eq!(search_view.active_match_index, Some(0));
1176 assert_eq!(
1177 search_view
1178 .results_editor
1179 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1180 [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
1181 );
1182
1183 search_view.select_match(Direction::Next, cx);
1184 });
1185
1186 search_view.update(cx, |search_view, cx| {
1187 assert_eq!(search_view.active_match_index, Some(1));
1188 assert_eq!(
1189 search_view
1190 .results_editor
1191 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1192 [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
1193 );
1194 search_view.select_match(Direction::Next, cx);
1195 });
1196
1197 search_view.update(cx, |search_view, cx| {
1198 assert_eq!(search_view.active_match_index, Some(2));
1199 assert_eq!(
1200 search_view
1201 .results_editor
1202 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1203 [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
1204 );
1205 search_view.select_match(Direction::Next, cx);
1206 });
1207
1208 search_view.update(cx, |search_view, cx| {
1209 assert_eq!(search_view.active_match_index, Some(0));
1210 assert_eq!(
1211 search_view
1212 .results_editor
1213 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1214 [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
1215 );
1216 search_view.select_match(Direction::Prev, cx);
1217 });
1218
1219 search_view.update(cx, |search_view, cx| {
1220 assert_eq!(search_view.active_match_index, Some(2));
1221 assert_eq!(
1222 search_view
1223 .results_editor
1224 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1225 [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
1226 );
1227 search_view.select_match(Direction::Prev, cx);
1228 });
1229
1230 search_view.update(cx, |search_view, cx| {
1231 assert_eq!(search_view.active_match_index, Some(1));
1232 assert_eq!(
1233 search_view
1234 .results_editor
1235 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1236 [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
1237 );
1238 });
1239 }
1240
1241 #[gpui::test]
1242 async fn test_project_search_focus(deterministic: Arc<Deterministic>, cx: &mut TestAppContext) {
1243 init_test(cx);
1244
1245 let fs = FakeFs::new(cx.background());
1246 fs.insert_tree(
1247 "/dir",
1248 json!({
1249 "one.rs": "const ONE: usize = 1;",
1250 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
1251 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
1252 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
1253 }),
1254 )
1255 .await;
1256 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
1257 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
1258
1259 let active_item = cx.read(|cx| {
1260 workspace
1261 .read(cx)
1262 .active_pane()
1263 .read(cx)
1264 .active_item()
1265 .and_then(|item| item.downcast::<ProjectSearchView>())
1266 });
1267 assert!(
1268 active_item.is_none(),
1269 "Expected no search panel to be active, but got: {active_item:?}"
1270 );
1271
1272 workspace.update(cx, |workspace, cx| {
1273 ProjectSearchView::deploy(workspace, &workspace::NewSearch, cx)
1274 });
1275
1276 let Some(search_view) = cx.read(|cx| {
1277 workspace
1278 .read(cx)
1279 .active_pane()
1280 .read(cx)
1281 .active_item()
1282 .and_then(|item| item.downcast::<ProjectSearchView>())
1283 }) else {
1284 panic!("Search view expected to appear after new search event trigger")
1285 };
1286 let search_view_id = search_view.id();
1287
1288 cx.spawn(
1289 |mut cx| async move { cx.dispatch_action(window_id, search_view_id, &ToggleFocus) },
1290 )
1291 .detach();
1292 deterministic.run_until_parked();
1293 search_view.update(cx, |search_view, cx| {
1294 assert!(
1295 search_view.query_editor.is_focused(cx),
1296 "Empty search view should be focused after the toggle focus event: no results panel to focus on",
1297 );
1298 });
1299
1300 search_view.update(cx, |search_view, cx| {
1301 let query_editor = &search_view.query_editor;
1302 assert!(
1303 query_editor.is_focused(cx),
1304 "Search view should be focused after the new search view is activated",
1305 );
1306 let query_text = query_editor.read(cx).text(cx);
1307 assert!(
1308 query_text.is_empty(),
1309 "New search query should be empty but got '{query_text}'",
1310 );
1311 let results_text = search_view
1312 .results_editor
1313 .update(cx, |editor, cx| editor.display_text(cx));
1314 assert!(
1315 results_text.is_empty(),
1316 "Empty search view should have no results but got '{results_text}'"
1317 );
1318 });
1319
1320 search_view.update(cx, |search_view, cx| {
1321 search_view.query_editor.update(cx, |query_editor, cx| {
1322 query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", cx)
1323 });
1324 search_view.search(cx);
1325 });
1326 deterministic.run_until_parked();
1327 search_view.update(cx, |search_view, cx| {
1328 let results_text = search_view
1329 .results_editor
1330 .update(cx, |editor, cx| editor.display_text(cx));
1331 assert!(
1332 results_text.is_empty(),
1333 "Search view for mismatching query should have no results but got '{results_text}'"
1334 );
1335 assert!(
1336 search_view.query_editor.is_focused(cx),
1337 "Search view should be focused after mismatching query had been used in search",
1338 );
1339 });
1340 cx.spawn(
1341 |mut cx| async move { cx.dispatch_action(window_id, search_view_id, &ToggleFocus) },
1342 )
1343 .detach();
1344 deterministic.run_until_parked();
1345 search_view.update(cx, |search_view, cx| {
1346 assert!(
1347 search_view.query_editor.is_focused(cx),
1348 "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
1349 );
1350 });
1351
1352 search_view.update(cx, |search_view, cx| {
1353 search_view
1354 .query_editor
1355 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
1356 search_view.search(cx);
1357 });
1358 deterministic.run_until_parked();
1359 search_view.update(cx, |search_view, cx| {
1360 assert_eq!(
1361 search_view
1362 .results_editor
1363 .update(cx, |editor, cx| editor.display_text(cx)),
1364 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
1365 "Search view results should match the query"
1366 );
1367 assert!(
1368 search_view.results_editor.is_focused(cx),
1369 "Search view with mismatching query should be focused after search results are available",
1370 );
1371 });
1372 cx.spawn(
1373 |mut cx| async move { cx.dispatch_action(window_id, search_view_id, &ToggleFocus) },
1374 )
1375 .detach();
1376 deterministic.run_until_parked();
1377 search_view.update(cx, |search_view, cx| {
1378 assert!(
1379 search_view.results_editor.is_focused(cx),
1380 "Search view with matching query should still have its results editor focused after the toggle focus event",
1381 );
1382 });
1383
1384 workspace.update(cx, |workspace, cx| {
1385 ProjectSearchView::deploy(workspace, &workspace::NewSearch, cx)
1386 });
1387 search_view.update(cx, |search_view, cx| {
1388 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");
1389 assert_eq!(
1390 search_view
1391 .results_editor
1392 .update(cx, |editor, cx| editor.display_text(cx)),
1393 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
1394 "Results should be unchanged after search view 2nd open in a row"
1395 );
1396 assert!(
1397 search_view.query_editor.is_focused(cx),
1398 "Focus should be moved into query editor again after search view 2nd open in a row"
1399 );
1400 });
1401
1402 cx.spawn(
1403 |mut cx| async move { cx.dispatch_action(window_id, search_view_id, &ToggleFocus) },
1404 )
1405 .detach();
1406 deterministic.run_until_parked();
1407 search_view.update(cx, |search_view, cx| {
1408 assert!(
1409 search_view.results_editor.is_focused(cx),
1410 "Search view with matching query should switch focus to the results editor after the toggle focus event",
1411 );
1412 });
1413 }
1414
1415 pub fn init_test(cx: &mut TestAppContext) {
1416 cx.foreground().forbid_parking();
1417 let fonts = cx.font_cache();
1418 let mut theme = gpui::fonts::with_font_cache(fonts.clone(), theme::Theme::default);
1419 theme.search.match_background = Color::red();
1420
1421 cx.update(|cx| {
1422 cx.set_global(SettingsStore::test(cx));
1423 cx.set_global(ActiveSearches::default());
1424
1425 theme::init((), cx);
1426 cx.update_global::<SettingsStore, _, _>(|store, _| {
1427 let mut settings = store.get::<ThemeSettings>(None).clone();
1428 settings.theme = Arc::new(theme);
1429 store.override_global(settings)
1430 });
1431
1432 language::init(cx);
1433 client::init_settings(cx);
1434 editor::init(cx);
1435 workspace::init_settings(cx);
1436 Project::init_settings(cx);
1437 super::init(cx);
1438 });
1439 }
1440}