1use crate::{
2 history::SearchHistory, mode::SearchMode, ActivateRegexMode, ActivateSemanticMode,
3 ActivateTextMode, CycleMode, NextHistoryQuery, PreviousHistoryQuery, ReplaceAll, ReplaceNext,
4 SearchOptions, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleIncludeIgnored,
5 ToggleReplace, ToggleWholeWord,
6};
7use anyhow::{Context as _, Result};
8use collections::HashMap;
9use editor::{
10 items::active_match_index, scroll::autoscroll::Autoscroll, Anchor, Editor, EditorEvent,
11 MultiBuffer, SelectAll, MAX_TAB_TITLE_LEN,
12};
13use editor::{EditorElement, EditorStyle};
14use gpui::{
15 actions, div, AnyElement, AnyView, AppContext, Context as _, Element, EntityId, EventEmitter,
16 FocusHandle, FocusableView, FontStyle, FontWeight, Hsla, InteractiveElement, IntoElement,
17 KeyContext, Model, ModelContext, ParentElement, PromptLevel, Render, SharedString, Styled,
18 Subscription, Task, TextStyle, View, ViewContext, VisualContext, WeakModel, WeakView,
19 WhiteSpace, WindowContext,
20};
21use menu::Confirm;
22use project::{
23 search::{SearchInputs, SearchQuery},
24 Entry, Project,
25};
26use semantic_index::{SemanticIndex, SemanticIndexStatus};
27
28use settings::Settings;
29use smol::stream::StreamExt;
30use std::{
31 any::{Any, TypeId},
32 collections::HashSet,
33 mem,
34 ops::{Not, Range},
35 path::PathBuf,
36 time::{Duration, Instant},
37};
38use theme::ThemeSettings;
39
40use ui::{
41 h_stack, prelude::*, v_stack, Icon, IconButton, IconElement, Label, LabelCommon, LabelSize,
42 Selectable, ToggleButton, Tooltip,
43};
44use util::{paths::PathMatcher, ResultExt as _};
45use workspace::{
46 item::{BreadcrumbText, Item, ItemEvent, ItemHandle},
47 searchable::{Direction, SearchableItem, SearchableItemHandle},
48 ItemNavHistory, Pane, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace,
49 WorkspaceId,
50};
51
52actions!(
53 project_search,
54 [SearchInNew, ToggleFocus, NextField, ToggleFilters]
55);
56
57#[derive(Default)]
58struct ActiveSearches(HashMap<WeakModel<Project>, WeakView<ProjectSearchView>>);
59
60#[derive(Default)]
61struct ActiveSettings(HashMap<WeakModel<Project>, ProjectSearchSettings>);
62
63pub fn init(cx: &mut AppContext) {
64 cx.set_global(ActiveSearches::default());
65 cx.set_global(ActiveSettings::default());
66 cx.observe_new_views(|workspace: &mut Workspace, _cx| {
67 workspace
68 .register_action(ProjectSearchView::new_search)
69 .register_action(ProjectSearchView::deploy_search)
70 .register_action(ProjectSearchBar::search_in_new);
71 })
72 .detach();
73}
74
75struct ProjectSearch {
76 project: Model<Project>,
77 excerpts: Model<MultiBuffer>,
78 pending_search: Option<Task<Option<()>>>,
79 match_ranges: Vec<Range<Anchor>>,
80 active_query: Option<SearchQuery>,
81 search_id: usize,
82 search_history: SearchHistory,
83 no_results: Option<bool>,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
87enum InputPanel {
88 Query,
89 Exclude,
90 Include,
91}
92
93pub struct ProjectSearchView {
94 focus_handle: FocusHandle,
95 model: Model<ProjectSearch>,
96 query_editor: View<Editor>,
97 replacement_editor: View<Editor>,
98 results_editor: View<Editor>,
99 semantic_state: Option<SemanticState>,
100 semantic_permissioned: Option<bool>,
101 search_options: SearchOptions,
102 panels_with_errors: HashSet<InputPanel>,
103 active_match_index: Option<usize>,
104 search_id: usize,
105 query_editor_was_focused: bool,
106 included_files_editor: View<Editor>,
107 excluded_files_editor: View<Editor>,
108 filters_enabled: bool,
109 replace_enabled: bool,
110 current_mode: SearchMode,
111 _subscriptions: Vec<Subscription>,
112}
113
114struct SemanticState {
115 index_status: SemanticIndexStatus,
116 maintain_rate_limit: Option<Task<()>>,
117 _subscription: Subscription,
118}
119
120#[derive(Debug, Clone)]
121struct ProjectSearchSettings {
122 search_options: SearchOptions,
123 filters_enabled: bool,
124 current_mode: SearchMode,
125}
126
127pub struct ProjectSearchBar {
128 active_project_search: Option<View<ProjectSearchView>>,
129 subscription: Option<Subscription>,
130}
131
132impl ProjectSearch {
133 fn new(project: Model<Project>, cx: &mut ModelContext<Self>) -> Self {
134 let replica_id = project.read(cx).replica_id();
135 let capability = project.read(cx).capability();
136
137 Self {
138 project,
139 excerpts: cx.new_model(|_| MultiBuffer::new(replica_id, capability)),
140 pending_search: Default::default(),
141 match_ranges: Default::default(),
142 active_query: None,
143 search_id: 0,
144 search_history: SearchHistory::default(),
145 no_results: None,
146 }
147 }
148
149 fn clone(&self, cx: &mut ModelContext<Self>) -> Model<Self> {
150 cx.new_model(|cx| Self {
151 project: self.project.clone(),
152 excerpts: self
153 .excerpts
154 .update(cx, |excerpts, cx| cx.new_model(|cx| excerpts.clone(cx))),
155 pending_search: Default::default(),
156 match_ranges: self.match_ranges.clone(),
157 active_query: self.active_query.clone(),
158 search_id: self.search_id,
159 search_history: self.search_history.clone(),
160 no_results: self.no_results.clone(),
161 })
162 }
163
164 fn search(&mut self, query: SearchQuery, cx: &mut ModelContext<Self>) {
165 let search = self
166 .project
167 .update(cx, |project, cx| project.search(query.clone(), cx));
168 self.search_id += 1;
169 self.search_history.add(query.as_str().to_string());
170 self.active_query = Some(query);
171 self.match_ranges.clear();
172 self.pending_search = Some(cx.spawn(|this, mut cx| async move {
173 let mut matches = search;
174 let this = this.upgrade()?;
175 this.update(&mut cx, |this, cx| {
176 this.match_ranges.clear();
177 this.excerpts.update(cx, |this, cx| this.clear(cx));
178 this.no_results = Some(true);
179 })
180 .ok()?;
181
182 while let Some((buffer, anchors)) = matches.next().await {
183 let mut ranges = this
184 .update(&mut cx, |this, cx| {
185 this.no_results = Some(false);
186 this.excerpts.update(cx, |excerpts, cx| {
187 excerpts.stream_excerpts_with_context_lines(buffer, anchors, 1, cx)
188 })
189 })
190 .ok()?;
191
192 while let Some(range) = ranges.next().await {
193 this.update(&mut cx, |this, _| this.match_ranges.push(range))
194 .ok()?;
195 }
196 this.update(&mut cx, |_, cx| cx.notify()).ok()?;
197 }
198
199 this.update(&mut cx, |this, cx| {
200 this.pending_search.take();
201 cx.notify();
202 })
203 .ok()?;
204
205 None
206 }));
207 cx.notify();
208 }
209
210 fn semantic_search(&mut self, inputs: &SearchInputs, cx: &mut ModelContext<Self>) {
211 let search = SemanticIndex::global(cx).map(|index| {
212 index.update(cx, |semantic_index, cx| {
213 semantic_index.search_project(
214 self.project.clone(),
215 inputs.as_str().to_owned(),
216 10,
217 inputs.files_to_include().to_vec(),
218 inputs.files_to_exclude().to_vec(),
219 cx,
220 )
221 })
222 });
223 self.search_id += 1;
224 self.match_ranges.clear();
225 self.search_history.add(inputs.as_str().to_string());
226 self.no_results = None;
227 self.pending_search = Some(cx.spawn(|this, mut cx| async move {
228 let results = search?.await.log_err()?;
229 let matches = results
230 .into_iter()
231 .map(|result| (result.buffer, vec![result.range.start..result.range.start]));
232
233 this.update(&mut cx, |this, cx| {
234 this.no_results = Some(true);
235 this.excerpts.update(cx, |excerpts, cx| {
236 excerpts.clear(cx);
237 });
238 })
239 .ok()?;
240 for (buffer, ranges) in matches {
241 let mut match_ranges = this
242 .update(&mut cx, |this, cx| {
243 this.no_results = Some(false);
244 this.excerpts.update(cx, |excerpts, cx| {
245 excerpts.stream_excerpts_with_context_lines(buffer, ranges, 3, cx)
246 })
247 })
248 .ok()?;
249 while let Some(match_range) = match_ranges.next().await {
250 this.update(&mut cx, |this, cx| {
251 this.match_ranges.push(match_range);
252 while let Ok(Some(match_range)) = match_ranges.try_next() {
253 this.match_ranges.push(match_range);
254 }
255 cx.notify();
256 })
257 .ok()?;
258 }
259 }
260
261 this.update(&mut cx, |this, cx| {
262 this.pending_search.take();
263 cx.notify();
264 })
265 .ok()?;
266
267 None
268 }));
269 cx.notify();
270 }
271}
272
273#[derive(Clone, Debug, PartialEq, Eq)]
274pub enum ViewEvent {
275 UpdateTab,
276 Activate,
277 EditorEvent(editor::EditorEvent),
278 Dismiss,
279}
280
281impl EventEmitter<ViewEvent> for ProjectSearchView {}
282
283impl Render for ProjectSearchView {
284 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
285 if self.has_matches() {
286 div()
287 .flex_1()
288 .size_full()
289 .track_focus(&self.focus_handle)
290 .child(self.results_editor.clone())
291 } else {
292 let model = self.model.read(cx);
293 let has_no_results = model.no_results.unwrap_or(false);
294 let is_search_underway = model.pending_search.is_some();
295 let mut major_text = if is_search_underway {
296 Label::new("Searching...")
297 } else if has_no_results {
298 Label::new("No results")
299 } else {
300 Label::new(format!("{} search all files", self.current_mode.label()))
301 };
302
303 let mut show_minor_text = true;
304 let semantic_status = self.semantic_state.as_ref().and_then(|semantic| {
305 let status = semantic.index_status;
306 match status {
307 SemanticIndexStatus::NotAuthenticated => {
308 major_text = Label::new("Not Authenticated");
309 show_minor_text = false;
310 Some(
311 "API Key Missing: Please set 'OPENAI_API_KEY' in Environment Variables. If you authenticated using the Assistant Panel, please restart Zed to Authenticate.".to_string())
312 }
313 SemanticIndexStatus::Indexed => Some("Indexing complete".to_string()),
314 SemanticIndexStatus::Indexing {
315 remaining_files,
316 rate_limit_expiry,
317 } => {
318 if remaining_files == 0 {
319 Some("Indexing...".to_string())
320 } else {
321 if let Some(rate_limit_expiry) = rate_limit_expiry {
322 let remaining_seconds =
323 rate_limit_expiry.duration_since(Instant::now());
324 if remaining_seconds > Duration::from_secs(0) {
325 Some(format!(
326 "Remaining files to index (rate limit resets in {}s): {}",
327 remaining_seconds.as_secs(),
328 remaining_files
329 ))
330 } else {
331 Some(format!("Remaining files to index: {}", remaining_files))
332 }
333 } else {
334 Some(format!("Remaining files to index: {}", remaining_files))
335 }
336 }
337 }
338 SemanticIndexStatus::NotIndexed => None,
339 }
340 });
341 let major_text = div().justify_center().max_w_96().child(major_text);
342
343 let minor_text: Option<SharedString> = if let Some(no_results) = model.no_results {
344 if model.pending_search.is_none() && no_results {
345 Some("No results found in this project for the provided query".into())
346 } else {
347 None
348 }
349 } else {
350 if let Some(mut semantic_status) = semantic_status {
351 semantic_status.extend(self.landing_text_minor().chars());
352 Some(semantic_status.into())
353 } else {
354 Some(self.landing_text_minor())
355 }
356 };
357 let minor_text = minor_text.map(|text| {
358 div()
359 .items_center()
360 .max_w_96()
361 .child(Label::new(text).size(LabelSize::Small))
362 });
363 v_stack()
364 .flex_1()
365 .size_full()
366 .justify_center()
367 .bg(cx.theme().colors().editor_background)
368 .track_focus(&self.focus_handle)
369 .child(
370 h_stack()
371 .size_full()
372 .justify_center()
373 .child(h_stack().flex_1())
374 .child(v_stack().child(major_text).children(minor_text))
375 .child(h_stack().flex_1()),
376 )
377 }
378 }
379}
380
381impl FocusableView for ProjectSearchView {
382 fn focus_handle(&self, _: &AppContext) -> gpui::FocusHandle {
383 self.focus_handle.clone()
384 }
385}
386
387impl Item for ProjectSearchView {
388 type Event = ViewEvent;
389 fn tab_tooltip_text(&self, cx: &AppContext) -> Option<SharedString> {
390 let query_text = self.query_editor.read(cx).text(cx);
391
392 query_text
393 .is_empty()
394 .not()
395 .then(|| query_text.into())
396 .or_else(|| Some("Project Search".into()))
397 }
398
399 fn act_as_type<'a>(
400 &'a self,
401 type_id: TypeId,
402 self_handle: &'a View<Self>,
403 _: &'a AppContext,
404 ) -> Option<AnyView> {
405 if type_id == TypeId::of::<Self>() {
406 Some(self_handle.clone().into())
407 } else if type_id == TypeId::of::<Editor>() {
408 Some(self.results_editor.clone().into())
409 } else {
410 None
411 }
412 }
413
414 fn deactivated(&mut self, cx: &mut ViewContext<Self>) {
415 self.results_editor
416 .update(cx, |editor, cx| editor.deactivated(cx));
417 }
418
419 fn tab_content(&self, _: Option<usize>, selected: bool, cx: &WindowContext<'_>) -> AnyElement {
420 let last_query: Option<SharedString> = self
421 .model
422 .read(cx)
423 .search_history
424 .current()
425 .as_ref()
426 .map(|query| {
427 let query_text = util::truncate_and_trailoff(query, MAX_TAB_TITLE_LEN);
428 query_text.into()
429 });
430 let tab_name = last_query
431 .filter(|query| !query.is_empty())
432 .unwrap_or_else(|| "Project search".into());
433 h_stack()
434 .gap_2()
435 .child(IconElement::new(Icon::MagnifyingGlass).color(if selected {
436 Color::Default
437 } else {
438 Color::Muted
439 }))
440 .child(Label::new(tab_name).color(if selected {
441 Color::Default
442 } else {
443 Color::Muted
444 }))
445 .into_any()
446 }
447
448 fn for_each_project_item(
449 &self,
450 cx: &AppContext,
451 f: &mut dyn FnMut(EntityId, &dyn project::Item),
452 ) {
453 self.results_editor.for_each_project_item(cx, f)
454 }
455
456 fn is_singleton(&self, _: &AppContext) -> bool {
457 false
458 }
459
460 fn can_save(&self, _: &AppContext) -> bool {
461 true
462 }
463
464 fn is_dirty(&self, cx: &AppContext) -> bool {
465 self.results_editor.read(cx).is_dirty(cx)
466 }
467
468 fn has_conflict(&self, cx: &AppContext) -> bool {
469 self.results_editor.read(cx).has_conflict(cx)
470 }
471
472 fn save(
473 &mut self,
474 project: Model<Project>,
475 cx: &mut ViewContext<Self>,
476 ) -> Task<anyhow::Result<()>> {
477 self.results_editor
478 .update(cx, |editor, cx| editor.save(project, cx))
479 }
480
481 fn save_as(
482 &mut self,
483 _: Model<Project>,
484 _: PathBuf,
485 _: &mut ViewContext<Self>,
486 ) -> Task<anyhow::Result<()>> {
487 unreachable!("save_as should not have been called")
488 }
489
490 fn reload(
491 &mut self,
492 project: Model<Project>,
493 cx: &mut ViewContext<Self>,
494 ) -> Task<anyhow::Result<()>> {
495 self.results_editor
496 .update(cx, |editor, cx| editor.reload(project, cx))
497 }
498
499 fn clone_on_split(
500 &self,
501 _workspace_id: WorkspaceId,
502 cx: &mut ViewContext<Self>,
503 ) -> Option<View<Self>>
504 where
505 Self: Sized,
506 {
507 let model = self.model.update(cx, |model, cx| model.clone(cx));
508 Some(cx.new_view(|cx| Self::new(model, cx, None)))
509 }
510
511 fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext<Self>) {
512 self.results_editor
513 .update(cx, |editor, cx| editor.added_to_workspace(workspace, cx));
514 }
515
516 fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut ViewContext<Self>) {
517 self.results_editor.update(cx, |editor, _| {
518 editor.set_nav_history(Some(nav_history));
519 });
520 }
521
522 fn navigate(&mut self, data: Box<dyn Any>, cx: &mut ViewContext<Self>) -> bool {
523 self.results_editor
524 .update(cx, |editor, cx| editor.navigate(data, cx))
525 }
526
527 fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) {
528 match event {
529 ViewEvent::UpdateTab => {
530 f(ItemEvent::UpdateBreadcrumbs);
531 f(ItemEvent::UpdateTab);
532 }
533 ViewEvent::EditorEvent(editor_event) => {
534 Editor::to_item_events(editor_event, f);
535 }
536 ViewEvent::Dismiss => f(ItemEvent::CloseItem),
537 _ => {}
538 }
539 }
540
541 fn breadcrumb_location(&self) -> ToolbarItemLocation {
542 if self.has_matches() {
543 ToolbarItemLocation::Secondary
544 } else {
545 ToolbarItemLocation::Hidden
546 }
547 }
548
549 fn breadcrumbs(&self, theme: &theme::Theme, cx: &AppContext) -> Option<Vec<BreadcrumbText>> {
550 self.results_editor.breadcrumbs(theme, cx)
551 }
552
553 fn serialized_item_kind() -> Option<&'static str> {
554 None
555 }
556
557 fn deserialize(
558 _project: Model<Project>,
559 _workspace: WeakView<Workspace>,
560 _workspace_id: workspace::WorkspaceId,
561 _item_id: workspace::ItemId,
562 _cx: &mut ViewContext<Pane>,
563 ) -> Task<anyhow::Result<View<Self>>> {
564 unimplemented!()
565 }
566}
567
568impl ProjectSearchView {
569 fn toggle_filters(&mut self, cx: &mut ViewContext<Self>) {
570 self.filters_enabled = !self.filters_enabled;
571 cx.update_global(|state: &mut ActiveSettings, cx| {
572 state.0.insert(
573 self.model.read(cx).project.downgrade(),
574 self.current_settings(),
575 );
576 });
577 }
578
579 fn current_settings(&self) -> ProjectSearchSettings {
580 ProjectSearchSettings {
581 search_options: self.search_options,
582 filters_enabled: self.filters_enabled,
583 current_mode: self.current_mode,
584 }
585 }
586 fn toggle_search_option(&mut self, option: SearchOptions, cx: &mut ViewContext<Self>) {
587 self.search_options.toggle(option);
588 cx.update_global(|state: &mut ActiveSettings, cx| {
589 state.0.insert(
590 self.model.read(cx).project.downgrade(),
591 self.current_settings(),
592 );
593 });
594 }
595
596 fn index_project(&mut self, cx: &mut ViewContext<Self>) {
597 if let Some(semantic_index) = SemanticIndex::global(cx) {
598 // Semantic search uses no options
599 self.search_options = SearchOptions::none();
600
601 let project = self.model.read(cx).project.clone();
602
603 semantic_index.update(cx, |semantic_index, cx| {
604 semantic_index
605 .index_project(project.clone(), cx)
606 .detach_and_log_err(cx);
607 });
608
609 self.semantic_state = Some(SemanticState {
610 index_status: semantic_index.read(cx).status(&project),
611 maintain_rate_limit: None,
612 _subscription: cx.observe(&semantic_index, Self::semantic_index_changed),
613 });
614 self.semantic_index_changed(semantic_index, cx);
615 }
616 }
617
618 fn semantic_index_changed(
619 &mut self,
620 semantic_index: Model<SemanticIndex>,
621 cx: &mut ViewContext<Self>,
622 ) {
623 let project = self.model.read(cx).project.clone();
624 if let Some(semantic_state) = self.semantic_state.as_mut() {
625 cx.notify();
626 semantic_state.index_status = semantic_index.read(cx).status(&project);
627 if let SemanticIndexStatus::Indexing {
628 rate_limit_expiry: Some(_),
629 ..
630 } = &semantic_state.index_status
631 {
632 if semantic_state.maintain_rate_limit.is_none() {
633 semantic_state.maintain_rate_limit =
634 Some(cx.spawn(|this, mut cx| async move {
635 loop {
636 cx.background_executor().timer(Duration::from_secs(1)).await;
637 this.update(&mut cx, |_, cx| cx.notify()).log_err();
638 }
639 }));
640 return;
641 }
642 } else {
643 semantic_state.maintain_rate_limit = None;
644 }
645 }
646 }
647
648 fn clear_search(&mut self, cx: &mut ViewContext<Self>) {
649 self.model.update(cx, |model, cx| {
650 model.pending_search = None;
651 model.no_results = None;
652 model.match_ranges.clear();
653
654 model.excerpts.update(cx, |excerpts, cx| {
655 excerpts.clear(cx);
656 });
657 });
658 }
659
660 fn activate_search_mode(&mut self, mode: SearchMode, cx: &mut ViewContext<Self>) {
661 let previous_mode = self.current_mode;
662 if previous_mode == mode {
663 return;
664 }
665
666 self.clear_search(cx);
667 self.current_mode = mode;
668 self.active_match_index = None;
669
670 match mode {
671 SearchMode::Semantic => {
672 let has_permission = self.semantic_permissioned(cx);
673 self.active_match_index = None;
674 cx.spawn(|this, mut cx| async move {
675 let has_permission = has_permission.await?;
676
677 if !has_permission {
678 let answer = this.update(&mut cx, |this, cx| {
679 let project = this.model.read(cx).project.clone();
680 let project_name = project
681 .read(cx)
682 .worktree_root_names(cx)
683 .collect::<Vec<&str>>()
684 .join("/");
685 let is_plural =
686 project_name.chars().filter(|letter| *letter == '/').count() > 0;
687 let prompt_text = format!("Would you like to index the '{}' project{} for semantic search? This requires sending code to the OpenAI API", project_name,
688 if is_plural {
689 "s"
690 } else {""});
691 cx.prompt(
692 PromptLevel::Info,
693 prompt_text.as_str(),
694 &["Continue", "Cancel"],
695 )
696 })?;
697
698 if answer.await? == 0 {
699 this.update(&mut cx, |this, _| {
700 this.semantic_permissioned = Some(true);
701 })?;
702 } else {
703 this.update(&mut cx, |this, cx| {
704 this.semantic_permissioned = Some(false);
705 debug_assert_ne!(previous_mode, SearchMode::Semantic, "Tried to re-enable semantic search mode after user modal was rejected");
706 this.activate_search_mode(previous_mode, cx);
707 })?;
708 return anyhow::Ok(());
709 }
710 }
711
712 this.update(&mut cx, |this, cx| {
713 this.index_project(cx);
714 })?;
715
716 anyhow::Ok(())
717 }).detach_and_log_err(cx);
718 }
719 SearchMode::Regex | SearchMode::Text => {
720 self.semantic_state = None;
721 self.active_match_index = None;
722 self.search(cx);
723 }
724 }
725
726 cx.update_global(|state: &mut ActiveSettings, cx| {
727 state.0.insert(
728 self.model.read(cx).project.downgrade(),
729 self.current_settings(),
730 );
731 });
732
733 cx.notify();
734 }
735 fn replace_next(&mut self, _: &ReplaceNext, cx: &mut ViewContext<Self>) {
736 let model = self.model.read(cx);
737 if let Some(query) = model.active_query.as_ref() {
738 if model.match_ranges.is_empty() {
739 return;
740 }
741 if let Some(active_index) = self.active_match_index {
742 let query = query.clone().with_replacement(self.replacement(cx));
743 self.results_editor.replace(
744 &(Box::new(model.match_ranges[active_index].clone()) as _),
745 &query,
746 cx,
747 );
748 self.select_match(Direction::Next, cx)
749 }
750 }
751 }
752 pub fn replacement(&self, cx: &AppContext) -> String {
753 self.replacement_editor.read(cx).text(cx)
754 }
755 fn replace_all(&mut self, _: &ReplaceAll, cx: &mut ViewContext<Self>) {
756 let model = self.model.read(cx);
757 if let Some(query) = model.active_query.as_ref() {
758 if model.match_ranges.is_empty() {
759 return;
760 }
761 if self.active_match_index.is_some() {
762 let query = query.clone().with_replacement(self.replacement(cx));
763 let matches = model
764 .match_ranges
765 .iter()
766 .map(|item| Box::new(item.clone()) as _)
767 .collect::<Vec<_>>();
768 for item in matches {
769 self.results_editor.replace(&item, &query, cx);
770 }
771 }
772 }
773 }
774
775 fn new(
776 model: Model<ProjectSearch>,
777 cx: &mut ViewContext<Self>,
778 settings: Option<ProjectSearchSettings>,
779 ) -> Self {
780 let project;
781 let excerpts;
782 let mut replacement_text = None;
783 let mut query_text = String::new();
784 let mut subscriptions = Vec::new();
785
786 // Read in settings if available
787 let (mut options, current_mode, filters_enabled) = if let Some(settings) = settings {
788 (
789 settings.search_options,
790 settings.current_mode,
791 settings.filters_enabled,
792 )
793 } else {
794 (SearchOptions::NONE, Default::default(), false)
795 };
796
797 {
798 let model = model.read(cx);
799 project = model.project.clone();
800 excerpts = model.excerpts.clone();
801 if let Some(active_query) = model.active_query.as_ref() {
802 query_text = active_query.as_str().to_string();
803 replacement_text = active_query.replacement().map(ToOwned::to_owned);
804 options = SearchOptions::from_query(active_query);
805 }
806 }
807 subscriptions.push(cx.observe(&model, |this, _, cx| this.model_changed(cx)));
808
809 let query_editor = cx.new_view(|cx| {
810 let mut editor = Editor::single_line(cx);
811 editor.set_placeholder_text("Text search all files", cx);
812 editor.set_text(query_text, cx);
813 editor
814 });
815 // Subscribe to query_editor in order to reraise editor events for workspace item activation purposes
816 subscriptions.push(
817 cx.subscribe(&query_editor, |_, _, event: &EditorEvent, cx| {
818 cx.emit(ViewEvent::EditorEvent(event.clone()))
819 }),
820 );
821 let replacement_editor = cx.new_view(|cx| {
822 let mut editor = Editor::single_line(cx);
823 editor.set_placeholder_text("Replace in project..", cx);
824 if let Some(text) = replacement_text {
825 editor.set_text(text, cx);
826 }
827 editor
828 });
829 let results_editor = cx.new_view(|cx| {
830 let mut editor = Editor::for_multibuffer(excerpts, Some(project.clone()), cx);
831 editor.set_searchable(false);
832 editor
833 });
834 subscriptions.push(cx.observe(&results_editor, |_, _, cx| cx.emit(ViewEvent::UpdateTab)));
835
836 subscriptions.push(
837 cx.subscribe(&results_editor, |this, _, event: &EditorEvent, cx| {
838 if matches!(event, editor::EditorEvent::SelectionsChanged { .. }) {
839 this.update_match_index(cx);
840 }
841 // Reraise editor events for workspace item activation purposes
842 cx.emit(ViewEvent::EditorEvent(event.clone()));
843 }),
844 );
845
846 let included_files_editor = cx.new_view(|cx| {
847 let mut editor = Editor::single_line(cx);
848 editor.set_placeholder_text("Include: crates/**/*.toml", cx);
849
850 editor
851 });
852 // Subscribe to include_files_editor in order to reraise editor events for workspace item activation purposes
853 subscriptions.push(
854 cx.subscribe(&included_files_editor, |_, _, event: &EditorEvent, cx| {
855 cx.emit(ViewEvent::EditorEvent(event.clone()))
856 }),
857 );
858
859 let excluded_files_editor = cx.new_view(|cx| {
860 let mut editor = Editor::single_line(cx);
861 editor.set_placeholder_text("Exclude: vendor/*, *.lock", cx);
862
863 editor
864 });
865 // Subscribe to excluded_files_editor in order to reraise editor events for workspace item activation purposes
866 subscriptions.push(
867 cx.subscribe(&excluded_files_editor, |_, _, event: &EditorEvent, cx| {
868 cx.emit(ViewEvent::EditorEvent(event.clone()))
869 }),
870 );
871
872 let focus_handle = cx.focus_handle();
873 subscriptions.push(cx.on_focus_in(&focus_handle, |this, cx| {
874 if this.focus_handle.is_focused(cx) {
875 if this.has_matches() {
876 this.results_editor.focus_handle(cx).focus(cx);
877 } else {
878 this.query_editor.focus_handle(cx).focus(cx);
879 }
880 }
881 }));
882
883 // Check if Worktrees have all been previously indexed
884 let mut this = ProjectSearchView {
885 focus_handle,
886 replacement_editor,
887 search_id: model.read(cx).search_id,
888 model,
889 query_editor,
890 results_editor,
891 semantic_state: None,
892 semantic_permissioned: None,
893 search_options: options,
894 panels_with_errors: HashSet::new(),
895 active_match_index: None,
896 query_editor_was_focused: false,
897 included_files_editor,
898 excluded_files_editor,
899 filters_enabled,
900 current_mode,
901 replace_enabled: false,
902 _subscriptions: subscriptions,
903 };
904 this.model_changed(cx);
905 this
906 }
907
908 fn semantic_permissioned(&mut self, cx: &mut ViewContext<Self>) -> Task<Result<bool>> {
909 if let Some(value) = self.semantic_permissioned {
910 return Task::ready(Ok(value));
911 }
912
913 SemanticIndex::global(cx)
914 .map(|semantic| {
915 let project = self.model.read(cx).project.clone();
916 semantic.update(cx, |this, cx| this.project_previously_indexed(&project, cx))
917 })
918 .unwrap_or(Task::ready(Ok(false)))
919 }
920
921 pub fn new_search_in_directory(
922 workspace: &mut Workspace,
923 dir_entry: &Entry,
924 cx: &mut ViewContext<Workspace>,
925 ) {
926 if !dir_entry.is_dir() {
927 return;
928 }
929 let Some(filter_str) = dir_entry.path.to_str() else {
930 return;
931 };
932
933 let model = cx.new_model(|cx| ProjectSearch::new(workspace.project().clone(), cx));
934 let search = cx.new_view(|cx| ProjectSearchView::new(model, cx, None));
935 workspace.add_item(Box::new(search.clone()), cx);
936 search.update(cx, |search, cx| {
937 search
938 .included_files_editor
939 .update(cx, |editor, cx| editor.set_text(filter_str, cx));
940 search.filters_enabled = true;
941 search.focus_query_editor(cx)
942 });
943 }
944
945 // Re-activate the most recently activated search or the most recent if it has been closed.
946 // If no search exists in the workspace, create a new one.
947 fn deploy_search(
948 workspace: &mut Workspace,
949 _: &workspace::DeploySearch,
950 cx: &mut ViewContext<Workspace>,
951 ) {
952 let active_search = cx
953 .global::<ActiveSearches>()
954 .0
955 .get(&workspace.project().downgrade());
956 let existing = active_search
957 .and_then(|active_search| {
958 workspace
959 .items_of_type::<ProjectSearchView>(cx)
960 .filter(|search| &search.downgrade() == active_search)
961 .last()
962 })
963 .or_else(|| workspace.item_of_type::<ProjectSearchView>(cx));
964 Self::existing_or_new_search(workspace, existing, cx)
965 }
966
967 // Add another search tab to the workspace.
968 fn new_search(
969 workspace: &mut Workspace,
970 _: &workspace::NewSearch,
971 cx: &mut ViewContext<Workspace>,
972 ) {
973 Self::existing_or_new_search(workspace, None, cx)
974 }
975
976 fn existing_or_new_search(
977 workspace: &mut Workspace,
978 existing: Option<View<ProjectSearchView>>,
979 cx: &mut ViewContext<Workspace>,
980 ) {
981 // Clean up entries for dropped projects
982 cx.update_global(|state: &mut ActiveSearches, _cx| {
983 state.0.retain(|project, _| project.is_upgradable())
984 });
985
986 let query = workspace.active_item(cx).and_then(|item| {
987 let editor = item.act_as::<Editor>(cx)?;
988 let query = editor.query_suggestion(cx);
989 if query.is_empty() {
990 None
991 } else {
992 Some(query)
993 }
994 });
995
996 let search = if let Some(existing) = existing {
997 workspace.activate_item(&existing, cx);
998 existing
999 } else {
1000 let settings = cx
1001 .global::<ActiveSettings>()
1002 .0
1003 .get(&workspace.project().downgrade());
1004
1005 let settings = if let Some(settings) = settings {
1006 Some(settings.clone())
1007 } else {
1008 None
1009 };
1010
1011 let model = cx.new_model(|cx| ProjectSearch::new(workspace.project().clone(), cx));
1012 let view = cx.new_view(|cx| ProjectSearchView::new(model, cx, settings));
1013
1014 workspace.add_item(Box::new(view.clone()), cx);
1015 view
1016 };
1017
1018 workspace.add_item(Box::new(search.clone()), cx);
1019
1020 search.update(cx, |search, cx| {
1021 if let Some(query) = query {
1022 search.set_query(&query, cx);
1023 }
1024 search.focus_query_editor(cx)
1025 });
1026 }
1027
1028 fn search(&mut self, cx: &mut ViewContext<Self>) {
1029 let mode = self.current_mode;
1030 match mode {
1031 SearchMode::Semantic => {
1032 if self.semantic_state.is_some() {
1033 if let Some(query) = self.build_search_query(cx) {
1034 self.model
1035 .update(cx, |model, cx| model.semantic_search(query.as_inner(), cx));
1036 }
1037 }
1038 }
1039
1040 _ => {
1041 if let Some(query) = self.build_search_query(cx) {
1042 self.model.update(cx, |model, cx| model.search(query, cx));
1043 }
1044 }
1045 }
1046 }
1047
1048 fn build_search_query(&mut self, cx: &mut ViewContext<Self>) -> Option<SearchQuery> {
1049 // Do not bail early in this function, as we want to fill out `self.panels_with_errors`.
1050 let text = self.query_editor.read(cx).text(cx);
1051 let included_files =
1052 match Self::parse_path_matches(&self.included_files_editor.read(cx).text(cx)) {
1053 Ok(included_files) => {
1054 let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Include);
1055 if should_unmark_error {
1056 cx.notify();
1057 }
1058 included_files
1059 }
1060 Err(_e) => {
1061 let should_mark_error = self.panels_with_errors.insert(InputPanel::Include);
1062 if should_mark_error {
1063 cx.notify();
1064 }
1065 vec![]
1066 }
1067 };
1068 let excluded_files =
1069 match Self::parse_path_matches(&self.excluded_files_editor.read(cx).text(cx)) {
1070 Ok(excluded_files) => {
1071 let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Exclude);
1072 if should_unmark_error {
1073 cx.notify();
1074 }
1075
1076 excluded_files
1077 }
1078 Err(_e) => {
1079 let should_mark_error = self.panels_with_errors.insert(InputPanel::Exclude);
1080 if should_mark_error {
1081 cx.notify();
1082 }
1083 vec![]
1084 }
1085 };
1086
1087 let current_mode = self.current_mode;
1088 let query = match current_mode {
1089 SearchMode::Regex => {
1090 match SearchQuery::regex(
1091 text,
1092 self.search_options.contains(SearchOptions::WHOLE_WORD),
1093 self.search_options.contains(SearchOptions::CASE_SENSITIVE),
1094 self.search_options.contains(SearchOptions::INCLUDE_IGNORED),
1095 included_files,
1096 excluded_files,
1097 ) {
1098 Ok(query) => {
1099 let should_unmark_error =
1100 self.panels_with_errors.remove(&InputPanel::Query);
1101 if should_unmark_error {
1102 cx.notify();
1103 }
1104
1105 Some(query)
1106 }
1107 Err(_e) => {
1108 let should_mark_error = self.panels_with_errors.insert(InputPanel::Query);
1109 if should_mark_error {
1110 cx.notify();
1111 }
1112
1113 None
1114 }
1115 }
1116 }
1117 _ => match SearchQuery::text(
1118 text,
1119 self.search_options.contains(SearchOptions::WHOLE_WORD),
1120 self.search_options.contains(SearchOptions::CASE_SENSITIVE),
1121 self.search_options.contains(SearchOptions::INCLUDE_IGNORED),
1122 included_files,
1123 excluded_files,
1124 ) {
1125 Ok(query) => {
1126 let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Query);
1127 if should_unmark_error {
1128 cx.notify();
1129 }
1130
1131 Some(query)
1132 }
1133 Err(_e) => {
1134 let should_mark_error = self.panels_with_errors.insert(InputPanel::Query);
1135 if should_mark_error {
1136 cx.notify();
1137 }
1138
1139 None
1140 }
1141 },
1142 };
1143 if !self.panels_with_errors.is_empty() {
1144 return None;
1145 }
1146 query
1147 }
1148
1149 fn parse_path_matches(text: &str) -> anyhow::Result<Vec<PathMatcher>> {
1150 text.split(',')
1151 .map(str::trim)
1152 .filter(|maybe_glob_str| !maybe_glob_str.is_empty())
1153 .map(|maybe_glob_str| {
1154 PathMatcher::new(maybe_glob_str)
1155 .with_context(|| format!("parsing {maybe_glob_str} as path matcher"))
1156 })
1157 .collect()
1158 }
1159
1160 fn select_match(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
1161 if let Some(index) = self.active_match_index {
1162 let match_ranges = self.model.read(cx).match_ranges.clone();
1163 let new_index = self.results_editor.update(cx, |editor, cx| {
1164 editor.match_index_for_direction(&match_ranges, index, direction, 1, cx)
1165 });
1166
1167 let range_to_select = match_ranges[new_index].clone();
1168 self.results_editor.update(cx, |editor, cx| {
1169 let range_to_select = editor.range_for_match(&range_to_select);
1170 editor.unfold_ranges([range_to_select.clone()], false, true, cx);
1171 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
1172 s.select_ranges([range_to_select])
1173 });
1174 });
1175 }
1176 }
1177
1178 fn focus_query_editor(&mut self, cx: &mut ViewContext<Self>) {
1179 self.query_editor.update(cx, |query_editor, cx| {
1180 query_editor.select_all(&SelectAll, cx);
1181 });
1182 self.query_editor_was_focused = true;
1183 let editor_handle = self.query_editor.focus_handle(cx);
1184 cx.focus(&editor_handle);
1185 }
1186
1187 fn set_query(&mut self, query: &str, cx: &mut ViewContext<Self>) {
1188 self.query_editor
1189 .update(cx, |query_editor, cx| query_editor.set_text(query, cx));
1190 }
1191
1192 fn focus_results_editor(&mut self, cx: &mut ViewContext<Self>) {
1193 self.query_editor.update(cx, |query_editor, cx| {
1194 let cursor = query_editor.selections.newest_anchor().head();
1195 query_editor.change_selections(None, cx, |s| s.select_ranges([cursor.clone()..cursor]));
1196 });
1197 self.query_editor_was_focused = false;
1198 let results_handle = self.results_editor.focus_handle(cx);
1199 cx.focus(&results_handle);
1200 }
1201
1202 fn model_changed(&mut self, cx: &mut ViewContext<Self>) {
1203 let match_ranges = self.model.read(cx).match_ranges.clone();
1204 if match_ranges.is_empty() {
1205 self.active_match_index = None;
1206 } else {
1207 self.active_match_index = Some(0);
1208 self.update_match_index(cx);
1209 let prev_search_id = mem::replace(&mut self.search_id, self.model.read(cx).search_id);
1210 let is_new_search = self.search_id != prev_search_id;
1211 self.results_editor.update(cx, |editor, cx| {
1212 if is_new_search {
1213 let range_to_select = match_ranges
1214 .first()
1215 .clone()
1216 .map(|range| editor.range_for_match(range));
1217 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
1218 s.select_ranges(range_to_select)
1219 });
1220 }
1221 editor.highlight_background::<Self>(
1222 match_ranges,
1223 |theme| theme.search_match_background,
1224 cx,
1225 );
1226 });
1227 if is_new_search && self.query_editor.focus_handle(cx).is_focused(cx) {
1228 self.focus_results_editor(cx);
1229 }
1230 }
1231
1232 cx.emit(ViewEvent::UpdateTab);
1233 cx.notify();
1234 }
1235
1236 fn update_match_index(&mut self, cx: &mut ViewContext<Self>) {
1237 let results_editor = self.results_editor.read(cx);
1238 let new_index = active_match_index(
1239 &self.model.read(cx).match_ranges,
1240 &results_editor.selections.newest_anchor().head(),
1241 &results_editor.buffer().read(cx).snapshot(cx),
1242 );
1243 if self.active_match_index != new_index {
1244 self.active_match_index = new_index;
1245 cx.notify();
1246 }
1247 }
1248
1249 pub fn has_matches(&self) -> bool {
1250 self.active_match_index.is_some()
1251 }
1252
1253 fn landing_text_minor(&self) -> SharedString {
1254 match self.current_mode {
1255 SearchMode::Text | SearchMode::Regex => "Include/exclude specific paths with the filter option. Matching exact word and/or casing is available too.".into(),
1256 SearchMode::Semantic => "\nSimply explain the code you are looking to find. ex. 'prompt user for permissions to index their project'".into()
1257 }
1258 }
1259 fn border_color_for(&self, panel: InputPanel, cx: &WindowContext) -> Hsla {
1260 if self.panels_with_errors.contains(&panel) {
1261 Color::Error.color(cx)
1262 } else {
1263 cx.theme().colors().border
1264 }
1265 }
1266 fn move_focus_to_results(&mut self, cx: &mut ViewContext<Self>) {
1267 if !self.results_editor.focus_handle(cx).is_focused(cx)
1268 && !self.model.read(cx).match_ranges.is_empty()
1269 {
1270 cx.stop_propagation();
1271 return self.focus_results_editor(cx);
1272 }
1273 }
1274}
1275
1276impl Default for ProjectSearchBar {
1277 fn default() -> Self {
1278 Self::new()
1279 }
1280}
1281
1282impl ProjectSearchBar {
1283 pub fn new() -> Self {
1284 Self {
1285 active_project_search: Default::default(),
1286 subscription: Default::default(),
1287 }
1288 }
1289
1290 fn cycle_mode(&self, _: &CycleMode, cx: &mut ViewContext<Self>) {
1291 if let Some(view) = self.active_project_search.as_ref() {
1292 view.update(cx, |this, cx| {
1293 let new_mode =
1294 crate::mode::next_mode(&this.current_mode, SemanticIndex::enabled(cx));
1295 this.activate_search_mode(new_mode, cx);
1296 let editor_handle = this.query_editor.focus_handle(cx);
1297 cx.focus(&editor_handle);
1298 });
1299 }
1300 }
1301
1302 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
1303 if let Some(search_view) = self.active_project_search.as_ref() {
1304 search_view.update(cx, |search_view, cx| {
1305 if !search_view
1306 .replacement_editor
1307 .focus_handle(cx)
1308 .is_focused(cx)
1309 {
1310 cx.stop_propagation();
1311 search_view.search(cx);
1312 }
1313 });
1314 }
1315 }
1316
1317 fn search_in_new(workspace: &mut Workspace, _: &SearchInNew, cx: &mut ViewContext<Workspace>) {
1318 if let Some(search_view) = workspace
1319 .active_item(cx)
1320 .and_then(|item| item.downcast::<ProjectSearchView>())
1321 {
1322 let new_query = search_view.update(cx, |search_view, cx| {
1323 let new_query = search_view.build_search_query(cx);
1324 if new_query.is_some() {
1325 if let Some(old_query) = search_view.model.read(cx).active_query.clone() {
1326 search_view.query_editor.update(cx, |editor, cx| {
1327 editor.set_text(old_query.as_str(), cx);
1328 });
1329 search_view.search_options = SearchOptions::from_query(&old_query);
1330 }
1331 }
1332 new_query
1333 });
1334 if let Some(new_query) = new_query {
1335 let model = cx.new_model(|cx| {
1336 let mut model = ProjectSearch::new(workspace.project().clone(), cx);
1337 model.search(new_query, cx);
1338 model
1339 });
1340 workspace.add_item(
1341 Box::new(cx.new_view(|cx| ProjectSearchView::new(model, cx, None))),
1342 cx,
1343 );
1344 }
1345 }
1346 }
1347
1348 fn tab(&mut self, _: &editor::Tab, cx: &mut ViewContext<Self>) {
1349 self.cycle_field(Direction::Next, cx);
1350 }
1351
1352 fn tab_previous(&mut self, _: &editor::TabPrev, cx: &mut ViewContext<Self>) {
1353 self.cycle_field(Direction::Prev, cx);
1354 }
1355
1356 fn cycle_field(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
1357 let active_project_search = match &self.active_project_search {
1358 Some(active_project_search) => active_project_search,
1359
1360 None => {
1361 return;
1362 }
1363 };
1364
1365 active_project_search.update(cx, |project_view, cx| {
1366 let mut views = vec![&project_view.query_editor];
1367 if project_view.filters_enabled {
1368 views.extend([
1369 &project_view.included_files_editor,
1370 &project_view.excluded_files_editor,
1371 ]);
1372 }
1373 if project_view.replace_enabled {
1374 views.push(&project_view.replacement_editor);
1375 }
1376 let current_index = match views
1377 .iter()
1378 .enumerate()
1379 .find(|(_, view)| view.focus_handle(cx).is_focused(cx))
1380 {
1381 Some((index, _)) => index,
1382
1383 None => {
1384 return;
1385 }
1386 };
1387
1388 let new_index = match direction {
1389 Direction::Next => (current_index + 1) % views.len(),
1390 Direction::Prev if current_index == 0 => views.len() - 1,
1391 Direction::Prev => (current_index - 1) % views.len(),
1392 };
1393 let next_focus_handle = views[new_index].focus_handle(cx);
1394 cx.focus(&next_focus_handle);
1395 cx.stop_propagation();
1396 });
1397 }
1398
1399 fn toggle_search_option(&mut self, option: SearchOptions, cx: &mut ViewContext<Self>) -> bool {
1400 if let Some(search_view) = self.active_project_search.as_ref() {
1401 search_view.update(cx, |search_view, cx| {
1402 search_view.toggle_search_option(option, cx);
1403 search_view.search(cx);
1404 });
1405
1406 cx.notify();
1407 true
1408 } else {
1409 false
1410 }
1411 }
1412
1413 fn toggle_replace(&mut self, _: &ToggleReplace, cx: &mut ViewContext<Self>) {
1414 if let Some(search) = &self.active_project_search {
1415 search.update(cx, |this, cx| {
1416 this.replace_enabled = !this.replace_enabled;
1417 let editor_to_focus = if !this.replace_enabled {
1418 this.query_editor.focus_handle(cx)
1419 } else {
1420 this.replacement_editor.focus_handle(cx)
1421 };
1422 cx.focus(&editor_to_focus);
1423 cx.notify();
1424 });
1425 }
1426 }
1427
1428 fn toggle_filters(&mut self, cx: &mut ViewContext<Self>) -> bool {
1429 if let Some(search_view) = self.active_project_search.as_ref() {
1430 search_view.update(cx, |search_view, cx| {
1431 search_view.toggle_filters(cx);
1432 search_view
1433 .included_files_editor
1434 .update(cx, |_, cx| cx.notify());
1435 search_view
1436 .excluded_files_editor
1437 .update(cx, |_, cx| cx.notify());
1438 cx.refresh();
1439 cx.notify();
1440 });
1441 cx.notify();
1442 true
1443 } else {
1444 false
1445 }
1446 }
1447
1448 fn move_focus_to_results(&self, cx: &mut ViewContext<Self>) {
1449 if let Some(search_view) = self.active_project_search.as_ref() {
1450 search_view.update(cx, |search_view, cx| {
1451 search_view.move_focus_to_results(cx);
1452 });
1453 cx.notify();
1454 }
1455 }
1456
1457 fn activate_search_mode(&self, mode: SearchMode, cx: &mut ViewContext<Self>) {
1458 // Update Current Mode
1459 if let Some(search_view) = self.active_project_search.as_ref() {
1460 search_view.update(cx, |search_view, cx| {
1461 search_view.activate_search_mode(mode, cx);
1462 });
1463 cx.notify();
1464 }
1465 }
1466
1467 fn is_option_enabled(&self, option: SearchOptions, cx: &AppContext) -> bool {
1468 if let Some(search) = self.active_project_search.as_ref() {
1469 search.read(cx).search_options.contains(option)
1470 } else {
1471 false
1472 }
1473 }
1474
1475 fn next_history_query(&mut self, _: &NextHistoryQuery, cx: &mut ViewContext<Self>) {
1476 if let Some(search_view) = self.active_project_search.as_ref() {
1477 search_view.update(cx, |search_view, cx| {
1478 let new_query = search_view.model.update(cx, |model, _| {
1479 if let Some(new_query) = model.search_history.next().map(str::to_string) {
1480 new_query
1481 } else {
1482 model.search_history.reset_selection();
1483 String::new()
1484 }
1485 });
1486 search_view.set_query(&new_query, cx);
1487 });
1488 }
1489 }
1490
1491 fn previous_history_query(&mut self, _: &PreviousHistoryQuery, cx: &mut ViewContext<Self>) {
1492 if let Some(search_view) = self.active_project_search.as_ref() {
1493 search_view.update(cx, |search_view, cx| {
1494 if search_view.query_editor.read(cx).text(cx).is_empty() {
1495 if let Some(new_query) = search_view
1496 .model
1497 .read(cx)
1498 .search_history
1499 .current()
1500 .map(str::to_string)
1501 {
1502 search_view.set_query(&new_query, cx);
1503 return;
1504 }
1505 }
1506
1507 if let Some(new_query) = search_view.model.update(cx, |model, _| {
1508 model.search_history.previous().map(str::to_string)
1509 }) {
1510 search_view.set_query(&new_query, cx);
1511 }
1512 });
1513 }
1514 }
1515
1516 fn new_placeholder_text(&self, cx: &mut ViewContext<Self>) -> Option<String> {
1517 let previous_query_keystrokes = cx
1518 .bindings_for_action(&PreviousHistoryQuery {})
1519 .into_iter()
1520 .next()
1521 .map(|binding| {
1522 binding
1523 .keystrokes()
1524 .iter()
1525 .map(|k| k.to_string())
1526 .collect::<Vec<_>>()
1527 });
1528 let next_query_keystrokes = cx
1529 .bindings_for_action(&NextHistoryQuery {})
1530 .into_iter()
1531 .next()
1532 .map(|binding| {
1533 binding
1534 .keystrokes()
1535 .iter()
1536 .map(|k| k.to_string())
1537 .collect::<Vec<_>>()
1538 });
1539 let new_placeholder_text = match (previous_query_keystrokes, next_query_keystrokes) {
1540 (Some(previous_query_keystrokes), Some(next_query_keystrokes)) => Some(format!(
1541 "Search ({}/{} for previous/next query)",
1542 previous_query_keystrokes.join(" "),
1543 next_query_keystrokes.join(" ")
1544 )),
1545 (None, Some(next_query_keystrokes)) => Some(format!(
1546 "Search ({} for next query)",
1547 next_query_keystrokes.join(" ")
1548 )),
1549 (Some(previous_query_keystrokes), None) => Some(format!(
1550 "Search ({} for previous query)",
1551 previous_query_keystrokes.join(" ")
1552 )),
1553 (None, None) => None,
1554 };
1555 new_placeholder_text
1556 }
1557
1558 fn render_text_input(&self, editor: &View<Editor>, cx: &ViewContext<Self>) -> impl IntoElement {
1559 let settings = ThemeSettings::get_global(cx);
1560 let text_style = TextStyle {
1561 color: if editor.read(cx).read_only(cx) {
1562 cx.theme().colors().text_disabled
1563 } else {
1564 cx.theme().colors().text
1565 },
1566 font_family: settings.ui_font.family.clone(),
1567 font_features: settings.ui_font.features,
1568 font_size: rems(0.875).into(),
1569 font_weight: FontWeight::NORMAL,
1570 font_style: FontStyle::Normal,
1571 line_height: relative(1.3).into(),
1572 background_color: None,
1573 underline: None,
1574 white_space: WhiteSpace::Normal,
1575 };
1576
1577 EditorElement::new(
1578 &editor,
1579 EditorStyle {
1580 background: cx.theme().colors().editor_background,
1581 local_player: cx.theme().players().local(),
1582 text: text_style,
1583 ..Default::default()
1584 },
1585 )
1586 }
1587}
1588
1589impl Render for ProjectSearchBar {
1590 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1591 let Some(search) = self.active_project_search.clone() else {
1592 return div();
1593 };
1594 let mut key_context = KeyContext::default();
1595 key_context.add("ProjectSearchBar");
1596 if let Some(placeholder_text) = self.new_placeholder_text(cx) {
1597 search.update(cx, |search, cx| {
1598 search.query_editor.update(cx, |this, cx| {
1599 this.set_placeholder_text(placeholder_text, cx)
1600 })
1601 });
1602 }
1603 let search = search.read(cx);
1604 let semantic_is_available = SemanticIndex::enabled(cx);
1605
1606 let query_column = v_stack().child(
1607 h_stack()
1608 .min_w(rems(512. / 16.))
1609 .px_2()
1610 .py_1()
1611 .gap_2()
1612 .bg(cx.theme().colors().editor_background)
1613 .border_1()
1614 .border_color(search.border_color_for(InputPanel::Query, cx))
1615 .rounded_lg()
1616 .on_action(cx.listener(|this, action, cx| this.confirm(action, cx)))
1617 .on_action(cx.listener(|this, action, cx| this.previous_history_query(action, cx)))
1618 .on_action(cx.listener(|this, action, cx| this.next_history_query(action, cx)))
1619 .child(IconElement::new(Icon::MagnifyingGlass))
1620 .child(self.render_text_input(&search.query_editor, cx))
1621 .child(
1622 h_stack()
1623 .child(
1624 IconButton::new("project-search-filter-button", Icon::Filter)
1625 .tooltip(|cx| {
1626 Tooltip::for_action("Toggle filters", &ToggleFilters, cx)
1627 })
1628 .on_click(cx.listener(|this, _, cx| {
1629 this.toggle_filters(cx);
1630 }))
1631 .selected(
1632 self.active_project_search
1633 .as_ref()
1634 .map(|search| search.read(cx).filters_enabled)
1635 .unwrap_or_default(),
1636 ),
1637 )
1638 .when(search.current_mode != SearchMode::Semantic, |this| {
1639 this.child(
1640 IconButton::new(
1641 "project-search-case-sensitive",
1642 Icon::CaseSensitive,
1643 )
1644 .tooltip(|cx| {
1645 Tooltip::for_action(
1646 "Toggle case sensitive",
1647 &ToggleCaseSensitive,
1648 cx,
1649 )
1650 })
1651 .selected(self.is_option_enabled(SearchOptions::CASE_SENSITIVE, cx))
1652 .on_click(cx.listener(
1653 |this, _, cx| {
1654 this.toggle_search_option(
1655 SearchOptions::CASE_SENSITIVE,
1656 cx,
1657 );
1658 },
1659 )),
1660 )
1661 .child(
1662 IconButton::new("project-search-whole-word", Icon::WholeWord)
1663 .tooltip(|cx| {
1664 Tooltip::for_action(
1665 "Toggle whole word",
1666 &ToggleWholeWord,
1667 cx,
1668 )
1669 })
1670 .selected(self.is_option_enabled(SearchOptions::WHOLE_WORD, cx))
1671 .on_click(cx.listener(|this, _, cx| {
1672 this.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
1673 })),
1674 )
1675 }),
1676 ),
1677 );
1678
1679 let mode_column = v_stack().items_start().justify_start().child(
1680 h_stack()
1681 .gap_2()
1682 .child(
1683 h_stack()
1684 .child(
1685 ToggleButton::new("project-search-text-button", "Text")
1686 .style(ButtonStyle::Filled)
1687 .size(ButtonSize::Large)
1688 .selected(search.current_mode == SearchMode::Text)
1689 .on_click(cx.listener(|this, _, cx| {
1690 this.activate_search_mode(SearchMode::Text, cx)
1691 }))
1692 .tooltip(|cx| {
1693 Tooltip::for_action("Toggle text search", &ActivateTextMode, cx)
1694 })
1695 .first(),
1696 )
1697 .child(
1698 ToggleButton::new("project-search-regex-button", "Regex")
1699 .style(ButtonStyle::Filled)
1700 .size(ButtonSize::Large)
1701 .selected(search.current_mode == SearchMode::Regex)
1702 .on_click(cx.listener(|this, _, cx| {
1703 this.activate_search_mode(SearchMode::Regex, cx)
1704 }))
1705 .tooltip(|cx| {
1706 Tooltip::for_action(
1707 "Toggle regular expression search",
1708 &ActivateRegexMode,
1709 cx,
1710 )
1711 })
1712 .map(|this| {
1713 if semantic_is_available {
1714 this.middle()
1715 } else {
1716 this.last()
1717 }
1718 }),
1719 )
1720 .when(semantic_is_available, |this| {
1721 this.child(
1722 ToggleButton::new("project-search-semantic-button", "Semantic")
1723 .style(ButtonStyle::Filled)
1724 .size(ButtonSize::Large)
1725 .selected(search.current_mode == SearchMode::Semantic)
1726 .on_click(cx.listener(|this, _, cx| {
1727 this.activate_search_mode(SearchMode::Semantic, cx)
1728 }))
1729 .tooltip(|cx| {
1730 Tooltip::for_action(
1731 "Toggle semantic search",
1732 &ActivateSemanticMode,
1733 cx,
1734 )
1735 })
1736 .last(),
1737 )
1738 }),
1739 )
1740 .child(
1741 IconButton::new("project-search-toggle-replace", Icon::Replace)
1742 .on_click(cx.listener(|this, _, cx| {
1743 this.toggle_replace(&ToggleReplace, cx);
1744 }))
1745 .tooltip(|cx| Tooltip::for_action("Toggle replace", &ToggleReplace, cx)),
1746 ),
1747 );
1748 let replace_column = if search.replace_enabled {
1749 h_stack()
1750 .flex_1()
1751 .h_full()
1752 .gap_2()
1753 .px_2()
1754 .py_1()
1755 .border_1()
1756 .border_color(cx.theme().colors().border)
1757 .rounded_lg()
1758 .child(IconElement::new(Icon::Replace).size(ui::IconSize::Small))
1759 .child(self.render_text_input(&search.replacement_editor, cx))
1760 } else {
1761 // Fill out the space if we don't have a replacement editor.
1762 h_stack().flex_1()
1763 };
1764 let actions_column = h_stack()
1765 .when(search.replace_enabled, |this| {
1766 this.child(
1767 IconButton::new("project-search-replace-next", Icon::ReplaceNext)
1768 .on_click(cx.listener(|this, _, cx| {
1769 if let Some(search) = this.active_project_search.as_ref() {
1770 search.update(cx, |this, cx| {
1771 this.replace_next(&ReplaceNext, cx);
1772 })
1773 }
1774 }))
1775 .tooltip(|cx| Tooltip::for_action("Replace next match", &ReplaceNext, cx)),
1776 )
1777 .child(
1778 IconButton::new("project-search-replace-all", Icon::ReplaceAll)
1779 .on_click(cx.listener(|this, _, cx| {
1780 if let Some(search) = this.active_project_search.as_ref() {
1781 search.update(cx, |this, cx| {
1782 this.replace_all(&ReplaceAll, cx);
1783 })
1784 }
1785 }))
1786 .tooltip(|cx| Tooltip::for_action("Replace all matches", &ReplaceAll, cx)),
1787 )
1788 })
1789 .when_some(search.active_match_index, |mut this, index| {
1790 let index = index + 1;
1791 let match_quantity = search.model.read(cx).match_ranges.len();
1792 if match_quantity > 0 {
1793 debug_assert!(match_quantity >= index);
1794 this = this.child(Label::new(format!("{index}/{match_quantity}")))
1795 }
1796 this
1797 })
1798 .child(
1799 IconButton::new("project-search-prev-match", Icon::ChevronLeft)
1800 .disabled(search.active_match_index.is_none())
1801 .on_click(cx.listener(|this, _, cx| {
1802 if let Some(search) = this.active_project_search.as_ref() {
1803 search.update(cx, |this, cx| {
1804 this.select_match(Direction::Prev, cx);
1805 })
1806 }
1807 }))
1808 .tooltip(|cx| {
1809 Tooltip::for_action("Go to previous match", &SelectPrevMatch, cx)
1810 }),
1811 )
1812 .child(
1813 IconButton::new("project-search-next-match", Icon::ChevronRight)
1814 .disabled(search.active_match_index.is_none())
1815 .on_click(cx.listener(|this, _, cx| {
1816 if let Some(search) = this.active_project_search.as_ref() {
1817 search.update(cx, |this, cx| {
1818 this.select_match(Direction::Next, cx);
1819 })
1820 }
1821 }))
1822 .tooltip(|cx| Tooltip::for_action("Go to next match", &SelectNextMatch, cx)),
1823 );
1824
1825 v_stack()
1826 .key_context(key_context)
1827 .flex_grow()
1828 .gap_2()
1829 .on_action(cx.listener(|this, _: &ToggleFocus, cx| this.move_focus_to_results(cx)))
1830 .on_action(cx.listener(|this, _: &ToggleFilters, cx| {
1831 this.toggle_filters(cx);
1832 }))
1833 .on_action(cx.listener(|this, _: &ActivateTextMode, cx| {
1834 this.activate_search_mode(SearchMode::Text, cx)
1835 }))
1836 .on_action(cx.listener(|this, _: &ActivateRegexMode, cx| {
1837 this.activate_search_mode(SearchMode::Regex, cx)
1838 }))
1839 .on_action(cx.listener(|this, _: &ActivateSemanticMode, cx| {
1840 this.activate_search_mode(SearchMode::Semantic, cx)
1841 }))
1842 .capture_action(cx.listener(|this, action, cx| {
1843 this.tab(action, cx);
1844 cx.stop_propagation();
1845 }))
1846 .capture_action(cx.listener(|this, action, cx| {
1847 this.tab_previous(action, cx);
1848 cx.stop_propagation();
1849 }))
1850 .on_action(cx.listener(|this, action, cx| this.confirm(action, cx)))
1851 .on_action(cx.listener(|this, action, cx| {
1852 this.cycle_mode(action, cx);
1853 }))
1854 .when(search.current_mode != SearchMode::Semantic, |this| {
1855 this.on_action(cx.listener(|this, action, cx| {
1856 this.toggle_replace(action, cx);
1857 }))
1858 .on_action(cx.listener(|this, _: &ToggleWholeWord, cx| {
1859 this.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
1860 }))
1861 .on_action(cx.listener(|this, _: &ToggleCaseSensitive, cx| {
1862 this.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
1863 }))
1864 .on_action(cx.listener(|this, action, cx| {
1865 if let Some(search) = this.active_project_search.as_ref() {
1866 search.update(cx, |this, cx| {
1867 this.replace_next(action, cx);
1868 })
1869 }
1870 }))
1871 .on_action(cx.listener(|this, action, cx| {
1872 if let Some(search) = this.active_project_search.as_ref() {
1873 search.update(cx, |this, cx| {
1874 this.replace_all(action, cx);
1875 })
1876 }
1877 }))
1878 .when(search.filters_enabled, |this| {
1879 this.on_action(cx.listener(|this, _: &ToggleIncludeIgnored, cx| {
1880 this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, cx);
1881 }))
1882 })
1883 })
1884 .child(
1885 h_stack()
1886 .justify_between()
1887 .gap_2()
1888 .child(query_column)
1889 .child(mode_column)
1890 .child(replace_column)
1891 .child(actions_column),
1892 )
1893 .when(search.filters_enabled, |this| {
1894 this.child(
1895 h_stack()
1896 .flex_1()
1897 .gap_2()
1898 .justify_between()
1899 .child(
1900 h_stack()
1901 .flex_1()
1902 .h_full()
1903 .px_2()
1904 .py_1()
1905 .border_1()
1906 .border_color(search.border_color_for(InputPanel::Include, cx))
1907 .rounded_lg()
1908 .child(self.render_text_input(&search.included_files_editor, cx))
1909 .when(search.current_mode != SearchMode::Semantic, |this| {
1910 this.child(
1911 SearchOptions::INCLUDE_IGNORED.as_button(
1912 search
1913 .search_options
1914 .contains(SearchOptions::INCLUDE_IGNORED),
1915 cx.listener(|this, _, cx| {
1916 this.toggle_search_option(
1917 SearchOptions::INCLUDE_IGNORED,
1918 cx,
1919 );
1920 }),
1921 ),
1922 )
1923 }),
1924 )
1925 .child(
1926 h_stack()
1927 .flex_1()
1928 .h_full()
1929 .px_2()
1930 .py_1()
1931 .border_1()
1932 .border_color(search.border_color_for(InputPanel::Exclude, cx))
1933 .rounded_lg()
1934 .child(self.render_text_input(&search.excluded_files_editor, cx)),
1935 ),
1936 )
1937 })
1938 }
1939}
1940
1941impl EventEmitter<ToolbarItemEvent> for ProjectSearchBar {}
1942
1943impl ToolbarItemView for ProjectSearchBar {
1944 fn set_active_pane_item(
1945 &mut self,
1946 active_pane_item: Option<&dyn ItemHandle>,
1947 cx: &mut ViewContext<Self>,
1948 ) -> ToolbarItemLocation {
1949 cx.notify();
1950 self.subscription = None;
1951 self.active_project_search = None;
1952 if let Some(search) = active_pane_item.and_then(|i| i.downcast::<ProjectSearchView>()) {
1953 search.update(cx, |search, cx| {
1954 if search.current_mode == SearchMode::Semantic {
1955 search.index_project(cx);
1956 }
1957 });
1958
1959 self.subscription = Some(cx.observe(&search, |_, _, cx| cx.notify()));
1960 self.active_project_search = Some(search);
1961 ToolbarItemLocation::PrimaryLeft {}
1962 } else {
1963 ToolbarItemLocation::Hidden
1964 }
1965 }
1966
1967 fn row_count(&self, cx: &WindowContext<'_>) -> usize {
1968 if let Some(search) = self.active_project_search.as_ref() {
1969 if search.read(cx).filters_enabled {
1970 return 2;
1971 }
1972 }
1973 1
1974 }
1975}
1976
1977#[cfg(test)]
1978pub mod tests {
1979 use super::*;
1980 use editor::DisplayPoint;
1981 use gpui::{Action, TestAppContext};
1982 use project::FakeFs;
1983 use semantic_index::semantic_index_settings::SemanticIndexSettings;
1984 use serde_json::json;
1985 use settings::{Settings, SettingsStore};
1986
1987 #[gpui::test]
1988 async fn test_project_search(cx: &mut TestAppContext) {
1989 init_test(cx);
1990
1991 let fs = FakeFs::new(cx.background_executor.clone());
1992 fs.insert_tree(
1993 "/dir",
1994 json!({
1995 "one.rs": "const ONE: usize = 1;",
1996 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
1997 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
1998 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
1999 }),
2000 )
2001 .await;
2002 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2003 let search = cx.new_model(|cx| ProjectSearch::new(project, cx));
2004 let search_view = cx.add_window(|cx| ProjectSearchView::new(search.clone(), cx, None));
2005
2006 search_view
2007 .update(cx, |search_view, cx| {
2008 search_view
2009 .query_editor
2010 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
2011 search_view.search(cx);
2012 })
2013 .unwrap();
2014 cx.background_executor.run_until_parked();
2015 search_view.update(cx, |search_view, cx| {
2016 assert_eq!(
2017 search_view
2018 .results_editor
2019 .update(cx, |editor, cx| editor.display_text(cx)),
2020 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;"
2021 );
2022 let match_background_color = cx.theme().colors().search_match_background;
2023 assert_eq!(
2024 search_view
2025 .results_editor
2026 .update(cx, |editor, cx| editor.all_text_background_highlights(cx)),
2027 &[
2028 (
2029 DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35),
2030 match_background_color
2031 ),
2032 (
2033 DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40),
2034 match_background_color
2035 ),
2036 (
2037 DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9),
2038 match_background_color
2039 )
2040 ]
2041 );
2042 assert_eq!(search_view.active_match_index, Some(0));
2043 assert_eq!(
2044 search_view
2045 .results_editor
2046 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2047 [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
2048 );
2049
2050 search_view.select_match(Direction::Next, cx);
2051 }).unwrap();
2052
2053 search_view
2054 .update(cx, |search_view, cx| {
2055 assert_eq!(search_view.active_match_index, Some(1));
2056 assert_eq!(
2057 search_view
2058 .results_editor
2059 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2060 [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
2061 );
2062 search_view.select_match(Direction::Next, cx);
2063 })
2064 .unwrap();
2065
2066 search_view
2067 .update(cx, |search_view, cx| {
2068 assert_eq!(search_view.active_match_index, Some(2));
2069 assert_eq!(
2070 search_view
2071 .results_editor
2072 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2073 [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
2074 );
2075 search_view.select_match(Direction::Next, cx);
2076 })
2077 .unwrap();
2078
2079 search_view
2080 .update(cx, |search_view, cx| {
2081 assert_eq!(search_view.active_match_index, Some(0));
2082 assert_eq!(
2083 search_view
2084 .results_editor
2085 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2086 [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
2087 );
2088 search_view.select_match(Direction::Prev, cx);
2089 })
2090 .unwrap();
2091
2092 search_view
2093 .update(cx, |search_view, cx| {
2094 assert_eq!(search_view.active_match_index, Some(2));
2095 assert_eq!(
2096 search_view
2097 .results_editor
2098 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2099 [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
2100 );
2101 search_view.select_match(Direction::Prev, cx);
2102 })
2103 .unwrap();
2104
2105 search_view
2106 .update(cx, |search_view, cx| {
2107 assert_eq!(search_view.active_match_index, Some(1));
2108 assert_eq!(
2109 search_view
2110 .results_editor
2111 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2112 [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
2113 );
2114 })
2115 .unwrap();
2116 }
2117
2118 #[gpui::test]
2119 async fn test_deploy_project_search_focus(cx: &mut TestAppContext) {
2120 init_test(cx);
2121
2122 let fs = FakeFs::new(cx.background_executor.clone());
2123 fs.insert_tree(
2124 "/dir",
2125 json!({
2126 "one.rs": "const ONE: usize = 1;",
2127 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2128 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2129 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2130 }),
2131 )
2132 .await;
2133 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2134 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2135 let workspace = window.clone();
2136 let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
2137
2138 let active_item = cx.read(|cx| {
2139 workspace
2140 .read(cx)
2141 .unwrap()
2142 .active_pane()
2143 .read(cx)
2144 .active_item()
2145 .and_then(|item| item.downcast::<ProjectSearchView>())
2146 });
2147 assert!(
2148 active_item.is_none(),
2149 "Expected no search panel to be active"
2150 );
2151
2152 window
2153 .update(cx, move |workspace, cx| {
2154 assert_eq!(workspace.panes().len(), 1);
2155 workspace.panes()[0].update(cx, move |pane, cx| {
2156 pane.toolbar()
2157 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2158 });
2159
2160 ProjectSearchView::deploy_search(workspace, &workspace::DeploySearch, cx)
2161 })
2162 .unwrap();
2163
2164 let Some(search_view) = cx.read(|cx| {
2165 workspace
2166 .read(cx)
2167 .unwrap()
2168 .active_pane()
2169 .read(cx)
2170 .active_item()
2171 .and_then(|item| item.downcast::<ProjectSearchView>())
2172 }) else {
2173 panic!("Search view expected to appear after new search event trigger")
2174 };
2175
2176 cx.spawn(|mut cx| async move {
2177 window
2178 .update(&mut cx, |_, cx| {
2179 cx.dispatch_action(ToggleFocus.boxed_clone())
2180 })
2181 .unwrap();
2182 })
2183 .detach();
2184 cx.background_executor.run_until_parked();
2185 window
2186 .update(cx, |_, cx| {
2187 search_view.update(cx, |search_view, cx| {
2188 assert!(
2189 search_view.query_editor.focus_handle(cx).is_focused(cx),
2190 "Empty search view should be focused after the toggle focus event: no results panel to focus on",
2191 );
2192 });
2193 }).unwrap();
2194
2195 window
2196 .update(cx, |_, cx| {
2197 search_view.update(cx, |search_view, cx| {
2198 let query_editor = &search_view.query_editor;
2199 assert!(
2200 query_editor.focus_handle(cx).is_focused(cx),
2201 "Search view should be focused after the new search view is activated",
2202 );
2203 let query_text = query_editor.read(cx).text(cx);
2204 assert!(
2205 query_text.is_empty(),
2206 "New search query should be empty but got '{query_text}'",
2207 );
2208 let results_text = search_view
2209 .results_editor
2210 .update(cx, |editor, cx| editor.display_text(cx));
2211 assert!(
2212 results_text.is_empty(),
2213 "Empty search view should have no results but got '{results_text}'"
2214 );
2215 });
2216 })
2217 .unwrap();
2218
2219 window
2220 .update(cx, |_, cx| {
2221 search_view.update(cx, |search_view, cx| {
2222 search_view.query_editor.update(cx, |query_editor, cx| {
2223 query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", cx)
2224 });
2225 search_view.search(cx);
2226 });
2227 })
2228 .unwrap();
2229 cx.background_executor.run_until_parked();
2230 window
2231 .update(cx, |_, cx| {
2232 search_view.update(cx, |search_view, cx| {
2233 let results_text = search_view
2234 .results_editor
2235 .update(cx, |editor, cx| editor.display_text(cx));
2236 assert!(
2237 results_text.is_empty(),
2238 "Search view for mismatching query should have no results but got '{results_text}'"
2239 );
2240 assert!(
2241 search_view.query_editor.focus_handle(cx).is_focused(cx),
2242 "Search view should be focused after mismatching query had been used in search",
2243 );
2244 });
2245 }).unwrap();
2246
2247 cx.spawn(|mut cx| async move {
2248 window.update(&mut cx, |_, cx| {
2249 cx.dispatch_action(ToggleFocus.boxed_clone())
2250 })
2251 })
2252 .detach();
2253 cx.background_executor.run_until_parked();
2254 window.update(cx, |_, cx| {
2255 search_view.update(cx, |search_view, cx| {
2256 assert!(
2257 search_view.query_editor.focus_handle(cx).is_focused(cx),
2258 "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
2259 );
2260 });
2261 }).unwrap();
2262
2263 window
2264 .update(cx, |_, cx| {
2265 search_view.update(cx, |search_view, cx| {
2266 search_view
2267 .query_editor
2268 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
2269 search_view.search(cx);
2270 });
2271 })
2272 .unwrap();
2273 cx.background_executor.run_until_parked();
2274 window.update(cx, |_, cx| {
2275 search_view.update(cx, |search_view, cx| {
2276 assert_eq!(
2277 search_view
2278 .results_editor
2279 .update(cx, |editor, cx| editor.display_text(cx)),
2280 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2281 "Search view results should match the query"
2282 );
2283 assert!(
2284 search_view.results_editor.focus_handle(cx).is_focused(cx),
2285 "Search view with mismatching query should be focused after search results are available",
2286 );
2287 });
2288 }).unwrap();
2289 cx.spawn(|mut cx| async move {
2290 window
2291 .update(&mut cx, |_, cx| {
2292 cx.dispatch_action(ToggleFocus.boxed_clone())
2293 })
2294 .unwrap();
2295 })
2296 .detach();
2297 cx.background_executor.run_until_parked();
2298 window.update(cx, |_, cx| {
2299 search_view.update(cx, |search_view, cx| {
2300 assert!(
2301 search_view.results_editor.focus_handle(cx).is_focused(cx),
2302 "Search view with matching query should still have its results editor focused after the toggle focus event",
2303 );
2304 });
2305 }).unwrap();
2306
2307 workspace
2308 .update(cx, |workspace, cx| {
2309 ProjectSearchView::deploy_search(workspace, &workspace::DeploySearch, cx)
2310 })
2311 .unwrap();
2312 window.update(cx, |_, cx| {
2313 search_view.update(cx, |search_view, cx| {
2314 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");
2315 assert_eq!(
2316 search_view
2317 .results_editor
2318 .update(cx, |editor, cx| editor.display_text(cx)),
2319 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2320 "Results should be unchanged after search view 2nd open in a row"
2321 );
2322 assert!(
2323 search_view.query_editor.focus_handle(cx).is_focused(cx),
2324 "Focus should be moved into query editor again after search view 2nd open in a row"
2325 );
2326 });
2327 }).unwrap();
2328
2329 cx.spawn(|mut cx| async move {
2330 window
2331 .update(&mut cx, |_, cx| {
2332 cx.dispatch_action(ToggleFocus.boxed_clone())
2333 })
2334 .unwrap();
2335 })
2336 .detach();
2337 cx.background_executor.run_until_parked();
2338 window.update(cx, |_, cx| {
2339 search_view.update(cx, |search_view, cx| {
2340 assert!(
2341 search_view.results_editor.focus_handle(cx).is_focused(cx),
2342 "Search view with matching query should switch focus to the results editor after the toggle focus event",
2343 );
2344 });
2345 }).unwrap();
2346 }
2347
2348 #[gpui::test]
2349 async fn test_new_project_search_focus(cx: &mut TestAppContext) {
2350 init_test(cx);
2351
2352 let fs = FakeFs::new(cx.background_executor.clone());
2353 fs.insert_tree(
2354 "/dir",
2355 json!({
2356 "one.rs": "const ONE: usize = 1;",
2357 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2358 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2359 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2360 }),
2361 )
2362 .await;
2363 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2364 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2365 let workspace = window.clone();
2366 let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
2367
2368 let active_item = cx.read(|cx| {
2369 workspace
2370 .read(cx)
2371 .unwrap()
2372 .active_pane()
2373 .read(cx)
2374 .active_item()
2375 .and_then(|item| item.downcast::<ProjectSearchView>())
2376 });
2377 assert!(
2378 active_item.is_none(),
2379 "Expected no search panel to be active"
2380 );
2381
2382 window
2383 .update(cx, move |workspace, cx| {
2384 assert_eq!(workspace.panes().len(), 1);
2385 workspace.panes()[0].update(cx, move |pane, cx| {
2386 pane.toolbar()
2387 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2388 });
2389
2390 ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2391 })
2392 .unwrap();
2393
2394 let Some(search_view) = cx.read(|cx| {
2395 workspace
2396 .read(cx)
2397 .unwrap()
2398 .active_pane()
2399 .read(cx)
2400 .active_item()
2401 .and_then(|item| item.downcast::<ProjectSearchView>())
2402 }) else {
2403 panic!("Search view expected to appear after new search event trigger")
2404 };
2405
2406 cx.spawn(|mut cx| async move {
2407 window
2408 .update(&mut cx, |_, cx| {
2409 cx.dispatch_action(ToggleFocus.boxed_clone())
2410 })
2411 .unwrap();
2412 })
2413 .detach();
2414 cx.background_executor.run_until_parked();
2415
2416 window.update(cx, |_, cx| {
2417 search_view.update(cx, |search_view, cx| {
2418 assert!(
2419 search_view.query_editor.focus_handle(cx).is_focused(cx),
2420 "Empty search view should be focused after the toggle focus event: no results panel to focus on",
2421 );
2422 });
2423 }).unwrap();
2424
2425 window
2426 .update(cx, |_, cx| {
2427 search_view.update(cx, |search_view, cx| {
2428 let query_editor = &search_view.query_editor;
2429 assert!(
2430 query_editor.focus_handle(cx).is_focused(cx),
2431 "Search view should be focused after the new search view is activated",
2432 );
2433 let query_text = query_editor.read(cx).text(cx);
2434 assert!(
2435 query_text.is_empty(),
2436 "New search query should be empty but got '{query_text}'",
2437 );
2438 let results_text = search_view
2439 .results_editor
2440 .update(cx, |editor, cx| editor.display_text(cx));
2441 assert!(
2442 results_text.is_empty(),
2443 "Empty search view should have no results but got '{results_text}'"
2444 );
2445 });
2446 })
2447 .unwrap();
2448
2449 window
2450 .update(cx, |_, cx| {
2451 search_view.update(cx, |search_view, cx| {
2452 search_view.query_editor.update(cx, |query_editor, cx| {
2453 query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", cx)
2454 });
2455 search_view.search(cx);
2456 });
2457 })
2458 .unwrap();
2459
2460 cx.background_executor.run_until_parked();
2461 window
2462 .update(cx, |_, cx| {
2463 search_view.update(cx, |search_view, cx| {
2464 let results_text = search_view
2465 .results_editor
2466 .update(cx, |editor, cx| editor.display_text(cx));
2467 assert!(
2468 results_text.is_empty(),
2469 "Search view for mismatching query should have no results but got '{results_text}'"
2470 );
2471 assert!(
2472 search_view.query_editor.focus_handle(cx).is_focused(cx),
2473 "Search view should be focused after mismatching query had been used in search",
2474 );
2475 });
2476 })
2477 .unwrap();
2478 cx.spawn(|mut cx| async move {
2479 window.update(&mut cx, |_, cx| {
2480 cx.dispatch_action(ToggleFocus.boxed_clone())
2481 })
2482 })
2483 .detach();
2484 cx.background_executor.run_until_parked();
2485 window.update(cx, |_, cx| {
2486 search_view.update(cx, |search_view, cx| {
2487 assert!(
2488 search_view.query_editor.focus_handle(cx).is_focused(cx),
2489 "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
2490 );
2491 });
2492 }).unwrap();
2493
2494 window
2495 .update(cx, |_, cx| {
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 })
2503 .unwrap();
2504 cx.background_executor.run_until_parked();
2505 window.update(cx, |_, cx|
2506 search_view.update(cx, |search_view, cx| {
2507 assert_eq!(
2508 search_view
2509 .results_editor
2510 .update(cx, |editor, cx| editor.display_text(cx)),
2511 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2512 "Search view results should match the query"
2513 );
2514 assert!(
2515 search_view.results_editor.focus_handle(cx).is_focused(cx),
2516 "Search view with mismatching query should be focused after search results are available",
2517 );
2518 })).unwrap();
2519 cx.spawn(|mut cx| async move {
2520 window
2521 .update(&mut cx, |_, cx| {
2522 cx.dispatch_action(ToggleFocus.boxed_clone())
2523 })
2524 .unwrap();
2525 })
2526 .detach();
2527 cx.background_executor.run_until_parked();
2528 window.update(cx, |_, cx| {
2529 search_view.update(cx, |search_view, cx| {
2530 assert!(
2531 search_view.results_editor.focus_handle(cx).is_focused(cx),
2532 "Search view with matching query should still have its results editor focused after the toggle focus event",
2533 );
2534 });
2535 }).unwrap();
2536
2537 workspace
2538 .update(cx, |workspace, cx| {
2539 ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2540 })
2541 .unwrap();
2542 cx.background_executor.run_until_parked();
2543 let Some(search_view_2) = cx.read(|cx| {
2544 workspace
2545 .read(cx)
2546 .unwrap()
2547 .active_pane()
2548 .read(cx)
2549 .active_item()
2550 .and_then(|item| item.downcast::<ProjectSearchView>())
2551 }) else {
2552 panic!("Search view expected to appear after new search event trigger")
2553 };
2554 assert!(
2555 search_view_2 != search_view,
2556 "New search view should be open after `workspace::NewSearch` event"
2557 );
2558
2559 window.update(cx, |_, cx| {
2560 search_view.update(cx, |search_view, cx| {
2561 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO", "First search view should not have an updated query");
2562 assert_eq!(
2563 search_view
2564 .results_editor
2565 .update(cx, |editor, cx| editor.display_text(cx)),
2566 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2567 "Results of the first search view should not update too"
2568 );
2569 assert!(
2570 !search_view.query_editor.focus_handle(cx).is_focused(cx),
2571 "Focus should be moved away from the first search view"
2572 );
2573 });
2574 }).unwrap();
2575
2576 window.update(cx, |_, cx| {
2577 search_view_2.update(cx, |search_view_2, cx| {
2578 assert_eq!(
2579 search_view_2.query_editor.read(cx).text(cx),
2580 "two",
2581 "New search view should get the query from the text cursor was at during the event spawn (first search view's first result)"
2582 );
2583 assert_eq!(
2584 search_view_2
2585 .results_editor
2586 .update(cx, |editor, cx| editor.display_text(cx)),
2587 "",
2588 "No search results should be in the 2nd view yet, as we did not spawn a search for it"
2589 );
2590 assert!(
2591 search_view_2.query_editor.focus_handle(cx).is_focused(cx),
2592 "Focus should be moved into query editor fo the new window"
2593 );
2594 });
2595 }).unwrap();
2596
2597 window
2598 .update(cx, |_, cx| {
2599 search_view_2.update(cx, |search_view_2, cx| {
2600 search_view_2
2601 .query_editor
2602 .update(cx, |query_editor, cx| query_editor.set_text("FOUR", cx));
2603 search_view_2.search(cx);
2604 });
2605 })
2606 .unwrap();
2607
2608 cx.background_executor.run_until_parked();
2609 window.update(cx, |_, cx| {
2610 search_view_2.update(cx, |search_view_2, cx| {
2611 assert_eq!(
2612 search_view_2
2613 .results_editor
2614 .update(cx, |editor, cx| editor.display_text(cx)),
2615 "\n\nconst FOUR: usize = one::ONE + three::THREE;",
2616 "New search view with the updated query should have new search results"
2617 );
2618 assert!(
2619 search_view_2.results_editor.focus_handle(cx).is_focused(cx),
2620 "Search view with mismatching query should be focused after search results are available",
2621 );
2622 });
2623 }).unwrap();
2624
2625 cx.spawn(|mut cx| async move {
2626 window
2627 .update(&mut cx, |_, cx| {
2628 cx.dispatch_action(ToggleFocus.boxed_clone())
2629 })
2630 .unwrap();
2631 })
2632 .detach();
2633 cx.background_executor.run_until_parked();
2634 window.update(cx, |_, cx| {
2635 search_view_2.update(cx, |search_view_2, cx| {
2636 assert!(
2637 search_view_2.results_editor.focus_handle(cx).is_focused(cx),
2638 "Search view with matching query should switch focus to the results editor after the toggle focus event",
2639 );
2640 });}).unwrap();
2641 }
2642
2643 #[gpui::test]
2644 async fn test_new_project_search_in_directory(cx: &mut TestAppContext) {
2645 init_test(cx);
2646
2647 let fs = FakeFs::new(cx.background_executor.clone());
2648 fs.insert_tree(
2649 "/dir",
2650 json!({
2651 "a": {
2652 "one.rs": "const ONE: usize = 1;",
2653 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2654 },
2655 "b": {
2656 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2657 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2658 },
2659 }),
2660 )
2661 .await;
2662 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2663 let worktree_id = project.read_with(cx, |project, cx| {
2664 project.worktrees().next().unwrap().read(cx).id()
2665 });
2666 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2667 let workspace = window.root(cx).unwrap();
2668 let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
2669
2670 let active_item = cx.read(|cx| {
2671 workspace
2672 .read(cx)
2673 .active_pane()
2674 .read(cx)
2675 .active_item()
2676 .and_then(|item| item.downcast::<ProjectSearchView>())
2677 });
2678 assert!(
2679 active_item.is_none(),
2680 "Expected no search panel to be active"
2681 );
2682
2683 window
2684 .update(cx, move |workspace, cx| {
2685 assert_eq!(workspace.panes().len(), 1);
2686 workspace.panes()[0].update(cx, move |pane, cx| {
2687 pane.toolbar()
2688 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2689 });
2690 })
2691 .unwrap();
2692
2693 let one_file_entry = cx.update(|cx| {
2694 workspace
2695 .read(cx)
2696 .project()
2697 .read(cx)
2698 .entry_for_path(&(worktree_id, "a/one.rs").into(), cx)
2699 .expect("no entry for /a/one.rs file")
2700 });
2701 assert!(one_file_entry.is_file());
2702 window
2703 .update(cx, |workspace, cx| {
2704 ProjectSearchView::new_search_in_directory(workspace, &one_file_entry, cx)
2705 })
2706 .unwrap();
2707 let active_search_entry = cx.read(|cx| {
2708 workspace
2709 .read(cx)
2710 .active_pane()
2711 .read(cx)
2712 .active_item()
2713 .and_then(|item| item.downcast::<ProjectSearchView>())
2714 });
2715 assert!(
2716 active_search_entry.is_none(),
2717 "Expected no search panel to be active for file entry"
2718 );
2719
2720 let a_dir_entry = cx.update(|cx| {
2721 workspace
2722 .read(cx)
2723 .project()
2724 .read(cx)
2725 .entry_for_path(&(worktree_id, "a").into(), cx)
2726 .expect("no entry for /a/ directory")
2727 });
2728 assert!(a_dir_entry.is_dir());
2729 window
2730 .update(cx, |workspace, cx| {
2731 ProjectSearchView::new_search_in_directory(workspace, &a_dir_entry, cx)
2732 })
2733 .unwrap();
2734
2735 let Some(search_view) = cx.read(|cx| {
2736 workspace
2737 .read(cx)
2738 .active_pane()
2739 .read(cx)
2740 .active_item()
2741 .and_then(|item| item.downcast::<ProjectSearchView>())
2742 }) else {
2743 panic!("Search view expected to appear after new search in directory event trigger")
2744 };
2745 cx.background_executor.run_until_parked();
2746 window
2747 .update(cx, |_, cx| {
2748 search_view.update(cx, |search_view, cx| {
2749 assert!(
2750 search_view.query_editor.focus_handle(cx).is_focused(cx),
2751 "On new search in directory, focus should be moved into query editor"
2752 );
2753 search_view.excluded_files_editor.update(cx, |editor, cx| {
2754 assert!(
2755 editor.display_text(cx).is_empty(),
2756 "New search in directory should not have any excluded files"
2757 );
2758 });
2759 search_view.included_files_editor.update(cx, |editor, cx| {
2760 assert_eq!(
2761 editor.display_text(cx),
2762 a_dir_entry.path.to_str().unwrap(),
2763 "New search in directory should have included dir entry path"
2764 );
2765 });
2766 });
2767 })
2768 .unwrap();
2769 window
2770 .update(cx, |_, cx| {
2771 search_view.update(cx, |search_view, cx| {
2772 search_view
2773 .query_editor
2774 .update(cx, |query_editor, cx| query_editor.set_text("const", cx));
2775 search_view.search(cx);
2776 });
2777 })
2778 .unwrap();
2779 cx.background_executor.run_until_parked();
2780 window
2781 .update(cx, |_, cx| {
2782 search_view.update(cx, |search_view, cx| {
2783 assert_eq!(
2784 search_view
2785 .results_editor
2786 .update(cx, |editor, cx| editor.display_text(cx)),
2787 "\n\nconst ONE: usize = 1;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2788 "New search in directory should have a filter that matches a certain directory"
2789 );
2790 })
2791 })
2792 .unwrap();
2793 }
2794
2795 #[gpui::test]
2796 async fn test_search_query_history(cx: &mut TestAppContext) {
2797 init_test(cx);
2798
2799 let fs = FakeFs::new(cx.background_executor.clone());
2800 fs.insert_tree(
2801 "/dir",
2802 json!({
2803 "one.rs": "const ONE: usize = 1;",
2804 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2805 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2806 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2807 }),
2808 )
2809 .await;
2810 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2811 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2812 let workspace = window.root(cx).unwrap();
2813 let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
2814
2815 window
2816 .update(cx, {
2817 let search_bar = search_bar.clone();
2818 move |workspace, cx| {
2819 assert_eq!(workspace.panes().len(), 1);
2820 workspace.panes()[0].update(cx, move |pane, cx| {
2821 pane.toolbar()
2822 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2823 });
2824
2825 ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2826 }
2827 })
2828 .unwrap();
2829
2830 let search_view = cx.read(|cx| {
2831 workspace
2832 .read(cx)
2833 .active_pane()
2834 .read(cx)
2835 .active_item()
2836 .and_then(|item| item.downcast::<ProjectSearchView>())
2837 .expect("Search view expected to appear after new search event trigger")
2838 });
2839
2840 // Add 3 search items into the history + another unsubmitted one.
2841 window
2842 .update(cx, |_, cx| {
2843 search_view.update(cx, |search_view, cx| {
2844 search_view.search_options = SearchOptions::CASE_SENSITIVE;
2845 search_view
2846 .query_editor
2847 .update(cx, |query_editor, cx| query_editor.set_text("ONE", cx));
2848 search_view.search(cx);
2849 });
2850 })
2851 .unwrap();
2852
2853 cx.background_executor.run_until_parked();
2854 window
2855 .update(cx, |_, cx| {
2856 search_view.update(cx, |search_view, cx| {
2857 search_view
2858 .query_editor
2859 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
2860 search_view.search(cx);
2861 });
2862 })
2863 .unwrap();
2864 cx.background_executor.run_until_parked();
2865 window
2866 .update(cx, |_, cx| {
2867 search_view.update(cx, |search_view, cx| {
2868 search_view
2869 .query_editor
2870 .update(cx, |query_editor, cx| query_editor.set_text("THREE", cx));
2871 search_view.search(cx);
2872 })
2873 })
2874 .unwrap();
2875 cx.background_executor.run_until_parked();
2876 window
2877 .update(cx, |_, cx| {
2878 search_view.update(cx, |search_view, cx| {
2879 search_view.query_editor.update(cx, |query_editor, cx| {
2880 query_editor.set_text("JUST_TEXT_INPUT", cx)
2881 });
2882 })
2883 })
2884 .unwrap();
2885 cx.background_executor.run_until_parked();
2886
2887 // Ensure that the latest input with search settings is active.
2888 window
2889 .update(cx, |_, cx| {
2890 search_view.update(cx, |search_view, cx| {
2891 assert_eq!(
2892 search_view.query_editor.read(cx).text(cx),
2893 "JUST_TEXT_INPUT"
2894 );
2895 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2896 });
2897 })
2898 .unwrap();
2899
2900 // Next history query after the latest should set the query to the empty string.
2901 window
2902 .update(cx, |_, cx| {
2903 search_bar.update(cx, |search_bar, cx| {
2904 search_bar.next_history_query(&NextHistoryQuery, cx);
2905 })
2906 })
2907 .unwrap();
2908 window
2909 .update(cx, |_, cx| {
2910 search_view.update(cx, |search_view, cx| {
2911 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
2912 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2913 });
2914 })
2915 .unwrap();
2916 window
2917 .update(cx, |_, cx| {
2918 search_bar.update(cx, |search_bar, cx| {
2919 search_bar.next_history_query(&NextHistoryQuery, cx);
2920 })
2921 })
2922 .unwrap();
2923 window
2924 .update(cx, |_, cx| {
2925 search_view.update(cx, |search_view, cx| {
2926 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
2927 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2928 });
2929 })
2930 .unwrap();
2931
2932 // First previous query for empty current query should set the query to the latest submitted one.
2933 window
2934 .update(cx, |_, cx| {
2935 search_bar.update(cx, |search_bar, cx| {
2936 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2937 });
2938 })
2939 .unwrap();
2940 window
2941 .update(cx, |_, cx| {
2942 search_view.update(cx, |search_view, cx| {
2943 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
2944 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2945 });
2946 })
2947 .unwrap();
2948
2949 // Further previous items should go over the history in reverse order.
2950 window
2951 .update(cx, |_, cx| {
2952 search_bar.update(cx, |search_bar, cx| {
2953 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2954 });
2955 })
2956 .unwrap();
2957 window
2958 .update(cx, |_, cx| {
2959 search_view.update(cx, |search_view, cx| {
2960 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
2961 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2962 });
2963 })
2964 .unwrap();
2965
2966 // Previous items should never go behind the first history item.
2967 window
2968 .update(cx, |_, cx| {
2969 search_bar.update(cx, |search_bar, cx| {
2970 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2971 });
2972 })
2973 .unwrap();
2974 window
2975 .update(cx, |_, cx| {
2976 search_view.update(cx, |search_view, cx| {
2977 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
2978 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2979 });
2980 })
2981 .unwrap();
2982 window
2983 .update(cx, |_, cx| {
2984 search_bar.update(cx, |search_bar, cx| {
2985 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2986 });
2987 })
2988 .unwrap();
2989 window
2990 .update(cx, |_, cx| {
2991 search_view.update(cx, |search_view, cx| {
2992 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
2993 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2994 });
2995 })
2996 .unwrap();
2997
2998 // Next items should go over the history in the original order.
2999 window
3000 .update(cx, |_, cx| {
3001 search_bar.update(cx, |search_bar, cx| {
3002 search_bar.next_history_query(&NextHistoryQuery, cx);
3003 });
3004 })
3005 .unwrap();
3006 window
3007 .update(cx, |_, cx| {
3008 search_view.update(cx, |search_view, cx| {
3009 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
3010 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3011 });
3012 })
3013 .unwrap();
3014
3015 window
3016 .update(cx, |_, cx| {
3017 search_view.update(cx, |search_view, cx| {
3018 search_view
3019 .query_editor
3020 .update(cx, |query_editor, cx| query_editor.set_text("TWO_NEW", cx));
3021 search_view.search(cx);
3022 });
3023 })
3024 .unwrap();
3025 cx.background_executor.run_until_parked();
3026 window
3027 .update(cx, |_, cx| {
3028 search_view.update(cx, |search_view, cx| {
3029 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
3030 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3031 });
3032 })
3033 .unwrap();
3034
3035 // New search input should add another entry to history and move the selection to the end of the history.
3036 window
3037 .update(cx, |_, cx| {
3038 search_bar.update(cx, |search_bar, cx| {
3039 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
3040 });
3041 })
3042 .unwrap();
3043 window
3044 .update(cx, |_, cx| {
3045 search_view.update(cx, |search_view, cx| {
3046 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
3047 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3048 });
3049 })
3050 .unwrap();
3051 window
3052 .update(cx, |_, cx| {
3053 search_bar.update(cx, |search_bar, cx| {
3054 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
3055 });
3056 })
3057 .unwrap();
3058 window
3059 .update(cx, |_, cx| {
3060 search_view.update(cx, |search_view, cx| {
3061 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
3062 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3063 });
3064 })
3065 .unwrap();
3066 window
3067 .update(cx, |_, cx| {
3068 search_bar.update(cx, |search_bar, cx| {
3069 search_bar.next_history_query(&NextHistoryQuery, cx);
3070 });
3071 })
3072 .unwrap();
3073 window
3074 .update(cx, |_, cx| {
3075 search_view.update(cx, |search_view, cx| {
3076 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
3077 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3078 });
3079 })
3080 .unwrap();
3081 window
3082 .update(cx, |_, cx| {
3083 search_bar.update(cx, |search_bar, cx| {
3084 search_bar.next_history_query(&NextHistoryQuery, cx);
3085 });
3086 })
3087 .unwrap();
3088 window
3089 .update(cx, |_, cx| {
3090 search_view.update(cx, |search_view, cx| {
3091 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
3092 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3093 });
3094 })
3095 .unwrap();
3096 window
3097 .update(cx, |_, cx| {
3098 search_bar.update(cx, |search_bar, cx| {
3099 search_bar.next_history_query(&NextHistoryQuery, cx);
3100 });
3101 })
3102 .unwrap();
3103 window
3104 .update(cx, |_, cx| {
3105 search_view.update(cx, |search_view, cx| {
3106 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
3107 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3108 });
3109 })
3110 .unwrap();
3111 }
3112
3113 pub fn init_test(cx: &mut TestAppContext) {
3114 cx.update(|cx| {
3115 let settings = SettingsStore::test(cx);
3116 cx.set_global(settings);
3117 cx.set_global(ActiveSearches::default());
3118 SemanticIndexSettings::register(cx);
3119
3120 theme::init(theme::LoadThemes::JustBase, cx);
3121
3122 language::init(cx);
3123 client::init_settings(cx);
3124 editor::init(cx);
3125 workspace::init_settings(cx);
3126 Project::init_settings(cx);
3127 super::init(cx);
3128 });
3129 }
3130}