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