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