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