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