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