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