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 let mode_column = v_stack().items_start().justify_start().child(
1528 h_stack()
1529 .child(
1530 h_stack()
1531 .child(
1532 Button::new("project-search-text-button", "Text")
1533 .selected(search.current_mode == SearchMode::Text)
1534 .on_click(cx.listener(|this, _, cx| {
1535 this.activate_search_mode(SearchMode::Text, cx)
1536 }))
1537 .tooltip(|cx| {
1538 Tooltip::for_action("Toggle text search", &ActivateTextMode, cx)
1539 }),
1540 )
1541 .child(
1542 Button::new("project-search-regex-button", "Regex")
1543 .selected(search.current_mode == SearchMode::Regex)
1544 .on_click(cx.listener(|this, _, cx| {
1545 this.activate_search_mode(SearchMode::Regex, cx)
1546 }))
1547 .tooltip(|cx| {
1548 Tooltip::for_action(
1549 "Toggle regular expression search",
1550 &ActivateRegexMode,
1551 cx,
1552 )
1553 }),
1554 )
1555 .when(semantic_is_available, |this| {
1556 this.child(
1557 Button::new("project-search-semantic-button", "Semantic")
1558 .selected(search.current_mode == SearchMode::Semantic)
1559 .on_click(cx.listener(|this, _, cx| {
1560 this.activate_search_mode(SearchMode::Semantic, cx)
1561 }))
1562 .tooltip(|cx| {
1563 Tooltip::for_action(
1564 "Toggle semantic search",
1565 &ActivateSemanticMode,
1566 cx,
1567 )
1568 }),
1569 )
1570 }),
1571 )
1572 .child(
1573 IconButton::new("project-search-toggle-replace", Icon::Replace)
1574 .on_click(cx.listener(|this, _, cx| {
1575 this.toggle_replace(&ToggleReplace, cx);
1576 }))
1577 .tooltip(|cx| Tooltip::for_action("Toggle replace", &ToggleReplace, cx)),
1578 ),
1579 );
1580 let replace_column = if search.replace_enabled {
1581 h_stack()
1582 .p_1()
1583 .flex_1()
1584 .border_2()
1585 .rounded_lg()
1586 .child(IconElement::new(Icon::Replace).size(ui::IconSize::Small))
1587 .child(search.replacement_editor.clone())
1588 } else {
1589 // Fill out the space if we don't have a replacement editor.
1590 h_stack().flex_1()
1591 };
1592 let actions_column = h_stack()
1593 .when(search.replace_enabled, |this| {
1594 this.children([
1595 IconButton::new("project-search-replace-next", Icon::ReplaceNext)
1596 .on_click(cx.listener(|this, _, cx| {
1597 if let Some(search) = this.active_project_search.as_ref() {
1598 search.update(cx, |this, cx| {
1599 this.replace_next(&ReplaceNext, cx);
1600 })
1601 }
1602 }))
1603 .tooltip(|cx| Tooltip::for_action("Replace next match", &ReplaceNext, cx)),
1604 IconButton::new("project-search-replace-all", Icon::ReplaceAll)
1605 .on_click(cx.listener(|this, _, cx| {
1606 if let Some(search) = this.active_project_search.as_ref() {
1607 search.update(cx, |this, cx| {
1608 this.replace_all(&ReplaceAll, cx);
1609 })
1610 }
1611 }))
1612 .tooltip(|cx| Tooltip::for_action("Replace all matches", &ReplaceAll, cx)),
1613 ])
1614 })
1615 .when_some(search.active_match_index, |mut this, index| {
1616 let index = index + 1;
1617 let match_quantity = search.model.read(cx).match_ranges.len();
1618 if match_quantity > 0 {
1619 debug_assert!(match_quantity >= index);
1620 this = this.child(Label::new(format!("{index}/{match_quantity}")))
1621 }
1622 this
1623 })
1624 .children([
1625 IconButton::new("project-search-prev-match", Icon::ChevronLeft)
1626 .disabled(search.active_match_index.is_none())
1627 .on_click(cx.listener(|this, _, cx| {
1628 if let Some(search) = this.active_project_search.as_ref() {
1629 search.update(cx, |this, cx| {
1630 this.select_match(Direction::Prev, cx);
1631 })
1632 }
1633 }))
1634 .tooltip(|cx| {
1635 Tooltip::for_action("Go to previous match", &SelectPrevMatch, cx)
1636 }),
1637 IconButton::new("project-search-next-match", Icon::ChevronRight)
1638 .disabled(search.active_match_index.is_none())
1639 .on_click(cx.listener(|this, _, cx| {
1640 if let Some(search) = this.active_project_search.as_ref() {
1641 search.update(cx, |this, cx| {
1642 this.select_match(Direction::Next, cx);
1643 })
1644 }
1645 }))
1646 .tooltip(|cx| Tooltip::for_action("Go to next match", &SelectNextMatch, cx)),
1647 ]);
1648 v_stack()
1649 .key_context(key_context)
1650 .p_1()
1651 .m_2()
1652 .justify_between()
1653 .on_action(cx.listener(|this, _: &ToggleFilters, cx| {
1654 this.toggle_filters(cx);
1655 }))
1656 .on_action(cx.listener(|this, _: &ActivateTextMode, cx| {
1657 this.activate_search_mode(SearchMode::Text, cx)
1658 }))
1659 .on_action(cx.listener(|this, _: &ActivateRegexMode, cx| {
1660 this.activate_search_mode(SearchMode::Regex, cx)
1661 }))
1662 .on_action(cx.listener(|this, _: &ActivateSemanticMode, cx| {
1663 this.activate_search_mode(SearchMode::Semantic, cx)
1664 }))
1665 .on_action(cx.listener(|this, action, cx| {
1666 this.tab(action, cx);
1667 }))
1668 .on_action(cx.listener(|this, action, cx| {
1669 this.tab_previous(action, cx);
1670 }))
1671 .on_action(cx.listener(|this, action, cx| {
1672 this.cycle_mode(action, cx);
1673 }))
1674 .when(search.current_mode != SearchMode::Semantic, |this| {
1675 this.on_action(cx.listener(|this, action, cx| {
1676 this.toggle_replace(action, cx);
1677 }))
1678 .on_action(cx.listener(|this, _: &ToggleWholeWord, cx| {
1679 this.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
1680 }))
1681 .on_action(cx.listener(|this, _: &ToggleCaseSensitive, cx| {
1682 this.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
1683 }))
1684 .on_action(cx.listener(|this, action, cx| {
1685 if let Some(search) = this.active_project_search.as_ref() {
1686 search.update(cx, |this, cx| {
1687 this.replace_next(action, cx);
1688 })
1689 }
1690 }))
1691 .on_action(cx.listener(|this, action, cx| {
1692 if let Some(search) = this.active_project_search.as_ref() {
1693 search.update(cx, |this, cx| {
1694 this.replace_all(action, cx);
1695 })
1696 }
1697 }))
1698 .when(search.filters_enabled, |this| {
1699 this.on_action(cx.listener(|this, _: &ToggleIncludeIgnored, cx| {
1700 this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, cx);
1701 }))
1702 })
1703 })
1704 .child(
1705 h_stack()
1706 .child(query_column)
1707 .child(mode_column)
1708 .child(replace_column)
1709 .child(actions_column),
1710 )
1711 .when(search.filters_enabled, |this| {
1712 this.child(
1713 h_stack()
1714 .mt_2()
1715 .flex_1()
1716 .justify_between()
1717 .child(
1718 h_stack()
1719 .flex_1()
1720 .border_1()
1721 .mr_2()
1722 .child(search.included_files_editor.clone())
1723 .when(search.current_mode != SearchMode::Semantic, |this| {
1724 this.child(
1725 SearchOptions::INCLUDE_IGNORED.as_button(
1726 search
1727 .search_options
1728 .contains(SearchOptions::INCLUDE_IGNORED),
1729 cx.listener(|this, _, cx| {
1730 this.toggle_search_option(
1731 SearchOptions::INCLUDE_IGNORED,
1732 cx,
1733 );
1734 }),
1735 ),
1736 )
1737 }),
1738 )
1739 .child(
1740 h_stack()
1741 .flex_1()
1742 .border_1()
1743 .ml_2()
1744 .child(search.excluded_files_editor.clone()),
1745 ),
1746 )
1747 })
1748 }
1749}
1750// impl Entity for ProjectSearchBar {
1751// type Event = ();
1752// }
1753
1754// impl View for ProjectSearchBar {
1755// fn ui_name() -> &'static str {
1756// "ProjectSearchBar"
1757// }
1758
1759// fn update_keymap_context(
1760// &self,
1761// keymap: &mut gpui::keymap_matcher::KeymapContext,
1762// cx: &AppContext,
1763// ) {
1764// Self::reset_to_default_keymap_context(keymap);
1765// let in_replace = self
1766// .active_project_search
1767// .as_ref()
1768// .map(|search| {
1769// search
1770// .read(cx)
1771// .replacement_editor
1772// .read_with(cx, |_, cx| cx.is_self_focused())
1773// })
1774// .flatten()
1775// .unwrap_or(false);
1776// if in_replace {
1777// keymap.add_identifier("in_replace");
1778// }
1779// }
1780
1781// fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
1782// if let Some(_search) = self.active_project_search.as_ref() {
1783// let search = _search.read(cx);
1784// let theme = theme::current(cx).clone();
1785// let query_container_style = if search.panels_with_errors.contains(&InputPanel::Query) {
1786// theme.search.invalid_editor
1787// } else {
1788// theme.search.editor.input.container
1789// };
1790
1791// let search = _search.read(cx);
1792// let filter_button = render_option_button_icon(
1793// search.filters_enabled,
1794// "icons/filter.svg",
1795// 0,
1796// "Toggle filters",
1797// Box::new(ToggleFilters),
1798// move |_, this, cx| {
1799// this.toggle_filters(cx);
1800// },
1801// cx,
1802// );
1803
1804// let search = _search.read(cx);
1805// let is_semantic_available = SemanticIndex::enabled(cx);
1806// let is_semantic_disabled = search.semantic_state.is_none();
1807// let icon_style = theme.search.editor_icon.clone();
1808// let is_active = search.active_match_index.is_some();
1809
1810// let render_option_button_icon = |path, option, cx: &mut ViewContext<Self>| {
1811// crate::search_bar::render_option_button_icon(
1812// self.is_option_enabled(option, cx),
1813// path,
1814// option.bits as usize,
1815// format!("Toggle {}", option.label()),
1816// option.to_toggle_action(),
1817// move |_, this, cx| {
1818// this.toggle_search_option(option, cx);
1819// },
1820// cx,
1821// )
1822// };
1823// let case_sensitive = is_semantic_disabled.then(|| {
1824// render_option_button_icon(
1825// "icons/case_insensitive.svg",
1826// SearchOptions::CASE_SENSITIVE,
1827// cx,
1828// )
1829// });
1830
1831// let whole_word = is_semantic_disabled.then(|| {
1832// render_option_button_icon("icons/word_search.svg", SearchOptions::WHOLE_WORD, cx)
1833// });
1834
1835// let include_ignored = is_semantic_disabled.then(|| {
1836// render_option_button_icon(
1837// "icons/file_icons/git.svg",
1838// SearchOptions::INCLUDE_IGNORED,
1839// cx,
1840// )
1841// });
1842
1843// let search_button_for_mode = |mode, side, cx: &mut ViewContext<ProjectSearchBar>| {
1844// let is_active = if let Some(search) = self.active_project_search.as_ref() {
1845// let search = search.read(cx);
1846// search.current_mode == mode
1847// } else {
1848// false
1849// };
1850// render_search_mode_button(
1851// mode,
1852// side,
1853// is_active,
1854// move |_, this, cx| {
1855// this.activate_search_mode(mode, cx);
1856// },
1857// cx,
1858// )
1859// };
1860
1861// let search = _search.read(cx);
1862
1863// let include_container_style =
1864// if search.panels_with_errors.contains(&InputPanel::Include) {
1865// theme.search.invalid_include_exclude_editor
1866// } else {
1867// theme.search.include_exclude_editor.input.container
1868// };
1869
1870// let exclude_container_style =
1871// if search.panels_with_errors.contains(&InputPanel::Exclude) {
1872// theme.search.invalid_include_exclude_editor
1873// } else {
1874// theme.search.include_exclude_editor.input.container
1875// };
1876
1877// let matches = search.active_match_index.map(|match_ix| {
1878// Label::new(
1879// format!(
1880// "{}/{}",
1881// match_ix + 1,
1882// search.model.read(cx).match_ranges.len()
1883// ),
1884// theme.search.match_index.text.clone(),
1885// )
1886// .contained()
1887// .with_style(theme.search.match_index.container)
1888// .aligned()
1889// });
1890// let should_show_replace_input = search.replace_enabled;
1891// let replacement = should_show_replace_input.then(|| {
1892// Flex::row()
1893// .with_child(
1894// Svg::for_style(theme.search.replace_icon.clone().icon)
1895// .contained()
1896// .with_style(theme.search.replace_icon.clone().container),
1897// )
1898// .with_child(ChildView::new(&search.replacement_editor, cx).flex(1., true))
1899// .align_children_center()
1900// .flex(1., true)
1901// .contained()
1902// .with_style(query_container_style)
1903// .constrained()
1904// .with_min_width(theme.search.editor.min_width)
1905// .with_max_width(theme.search.editor.max_width)
1906// .with_height(theme.search.search_bar_row_height)
1907// .flex(1., false)
1908// });
1909// let replace_all = should_show_replace_input.then(|| {
1910// super::replace_action(
1911// ReplaceAll,
1912// "Replace all",
1913// "icons/replace_all.svg",
1914// theme.tooltip.clone(),
1915// theme.search.action_button.clone(),
1916// )
1917// });
1918// let replace_next = should_show_replace_input.then(|| {
1919// super::replace_action(
1920// ReplaceNext,
1921// "Replace next",
1922// "icons/replace_next.svg",
1923// theme.tooltip.clone(),
1924// theme.search.action_button.clone(),
1925// )
1926// });
1927// let query_column = Flex::column()
1928// .with_spacing(theme.search.search_row_spacing)
1929// .with_child(
1930// Flex::row()
1931// .with_child(
1932// Svg::for_style(icon_style.icon)
1933// .contained()
1934// .with_style(icon_style.container),
1935// )
1936// .with_child(ChildView::new(&search.query_editor, cx).flex(1., true))
1937// .with_child(
1938// Flex::row()
1939// .with_child(filter_button)
1940// .with_children(case_sensitive)
1941// .with_children(whole_word)
1942// .flex(1., false)
1943// .constrained()
1944// .contained(),
1945// )
1946// .align_children_center()
1947// .contained()
1948// .with_style(query_container_style)
1949// .constrained()
1950// .with_min_width(theme.search.editor.min_width)
1951// .with_max_width(theme.search.editor.max_width)
1952// .with_height(theme.search.search_bar_row_height)
1953// .flex(1., false),
1954// )
1955// .with_children(search.filters_enabled.then(|| {
1956// Flex::row()
1957// .with_child(
1958// Flex::row()
1959// .with_child(
1960// ChildView::new(&search.included_files_editor, cx)
1961// .contained()
1962// .constrained()
1963// .with_height(theme.search.search_bar_row_height)
1964// .flex(1., true),
1965// )
1966// .with_children(include_ignored)
1967// .contained()
1968// .with_style(include_container_style)
1969// .constrained()
1970// .with_height(theme.search.search_bar_row_height)
1971// .flex(1., true),
1972// )
1973// .with_child(
1974// ChildView::new(&search.excluded_files_editor, cx)
1975// .contained()
1976// .with_style(exclude_container_style)
1977// .constrained()
1978// .with_height(theme.search.search_bar_row_height)
1979// .flex(1., true),
1980// )
1981// .constrained()
1982// .with_min_width(theme.search.editor.min_width)
1983// .with_max_width(theme.search.editor.max_width)
1984// .flex(1., false)
1985// }))
1986// .flex(1., false);
1987// let switches_column = Flex::row()
1988// .align_children_center()
1989// .with_child(super::toggle_replace_button(
1990// search.replace_enabled,
1991// theme.tooltip.clone(),
1992// theme.search.option_button_component.clone(),
1993// ))
1994// .constrained()
1995// .with_height(theme.search.search_bar_row_height)
1996// .contained()
1997// .with_style(theme.search.option_button_group);
1998// let mode_column =
1999// Flex::row()
2000// .with_child(search_button_for_mode(
2001// SearchMode::Text,
2002// Some(Side::Left),
2003// cx,
2004// ))
2005// .with_child(search_button_for_mode(
2006// SearchMode::Regex,
2007// if is_semantic_available {
2008// None
2009// } else {
2010// Some(Side::Right)
2011// },
2012// cx,
2013// ))
2014// .with_children(is_semantic_available.then(|| {
2015// search_button_for_mode(SearchMode::Semantic, Some(Side::Right), cx)
2016// }))
2017// .contained()
2018// .with_style(theme.search.modes_container);
2019
2020// let nav_button_for_direction = |label, direction, cx: &mut ViewContext<Self>| {
2021// render_nav_button(
2022// label,
2023// direction,
2024// is_active,
2025// move |_, this, cx| {
2026// if let Some(search) = this.active_project_search.as_ref() {
2027// search.update(cx, |search, cx| search.select_match(direction, cx));
2028// }
2029// },
2030// cx,
2031// )
2032// };
2033
2034// let nav_column = Flex::row()
2035// .with_children(replace_next)
2036// .with_children(replace_all)
2037// .with_child(Flex::row().with_children(matches))
2038// .with_child(nav_button_for_direction("<", Direction::Prev, cx))
2039// .with_child(nav_button_for_direction(">", Direction::Next, cx))
2040// .constrained()
2041// .with_height(theme.search.search_bar_row_height)
2042// .flex_float();
2043
2044// Flex::row()
2045// .with_child(query_column)
2046// .with_child(mode_column)
2047// .with_child(switches_column)
2048// .with_children(replacement)
2049// .with_child(nav_column)
2050// .contained()
2051// .with_style(theme.search.container)
2052// .into_any_named("project search")
2053// } else {
2054// Empty::new().into_any()
2055// }
2056// }
2057// }
2058
2059impl EventEmitter<ToolbarItemEvent> for ProjectSearchBar {}
2060
2061impl ToolbarItemView for ProjectSearchBar {
2062 fn set_active_pane_item(
2063 &mut self,
2064 active_pane_item: Option<&dyn ItemHandle>,
2065 cx: &mut ViewContext<Self>,
2066 ) -> ToolbarItemLocation {
2067 cx.notify();
2068 self.subscription = None;
2069 self.active_project_search = None;
2070 if let Some(search) = active_pane_item.and_then(|i| i.downcast::<ProjectSearchView>()) {
2071 search.update(cx, |search, cx| {
2072 if search.current_mode == SearchMode::Semantic {
2073 search.index_project(cx);
2074 }
2075 });
2076
2077 self.subscription = Some(cx.observe(&search, |_, _, cx| cx.notify()));
2078 self.active_project_search = Some(search);
2079 ToolbarItemLocation::PrimaryLeft {}
2080 } else {
2081 ToolbarItemLocation::Hidden
2082 }
2083 }
2084
2085 fn row_count(&self, cx: &WindowContext<'_>) -> usize {
2086 if let Some(search) = self.active_project_search.as_ref() {
2087 if search.read(cx).filters_enabled {
2088 return 2;
2089 }
2090 }
2091 1
2092 }
2093}
2094
2095#[cfg(test)]
2096pub mod tests {
2097 use super::*;
2098 use editor::DisplayPoint;
2099 use gpui::{Action, TestAppContext};
2100 use project::FakeFs;
2101 use semantic_index::semantic_index_settings::SemanticIndexSettings;
2102 use serde_json::json;
2103 use settings::{Settings, SettingsStore};
2104
2105 #[gpui::test]
2106 async fn test_project_search(cx: &mut TestAppContext) {
2107 init_test(cx);
2108
2109 let fs = FakeFs::new(cx.background_executor.clone());
2110 fs.insert_tree(
2111 "/dir",
2112 json!({
2113 "one.rs": "const ONE: usize = 1;",
2114 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2115 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2116 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2117 }),
2118 )
2119 .await;
2120 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2121 let search = cx.build_model(|cx| ProjectSearch::new(project, cx));
2122 let search_view = cx.add_window(|cx| ProjectSearchView::new(search.clone(), cx, None));
2123
2124 search_view
2125 .update(cx, |search_view, cx| {
2126 search_view
2127 .query_editor
2128 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
2129 search_view.search(cx);
2130 })
2131 .unwrap();
2132 cx.background_executor.run_until_parked();
2133 search_view.update(cx, |search_view, cx| {
2134 assert_eq!(
2135 search_view
2136 .results_editor
2137 .update(cx, |editor, cx| editor.display_text(cx)),
2138 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;"
2139 );
2140 let match_background_color = cx.theme().colors().search_match_background;
2141 assert_eq!(
2142 search_view
2143 .results_editor
2144 .update(cx, |editor, cx| editor.all_text_background_highlights(cx)),
2145 &[
2146 (
2147 DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35),
2148 match_background_color
2149 ),
2150 (
2151 DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40),
2152 match_background_color
2153 ),
2154 (
2155 DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9),
2156 match_background_color
2157 )
2158 ]
2159 );
2160 assert_eq!(search_view.active_match_index, Some(0));
2161 assert_eq!(
2162 search_view
2163 .results_editor
2164 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2165 [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
2166 );
2167
2168 search_view.select_match(Direction::Next, cx);
2169 }).unwrap();
2170
2171 search_view
2172 .update(cx, |search_view, cx| {
2173 assert_eq!(search_view.active_match_index, Some(1));
2174 assert_eq!(
2175 search_view
2176 .results_editor
2177 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2178 [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
2179 );
2180 search_view.select_match(Direction::Next, cx);
2181 })
2182 .unwrap();
2183
2184 search_view
2185 .update(cx, |search_view, cx| {
2186 assert_eq!(search_view.active_match_index, Some(2));
2187 assert_eq!(
2188 search_view
2189 .results_editor
2190 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2191 [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
2192 );
2193 search_view.select_match(Direction::Next, cx);
2194 })
2195 .unwrap();
2196
2197 search_view
2198 .update(cx, |search_view, cx| {
2199 assert_eq!(search_view.active_match_index, Some(0));
2200 assert_eq!(
2201 search_view
2202 .results_editor
2203 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2204 [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
2205 );
2206 search_view.select_match(Direction::Prev, cx);
2207 })
2208 .unwrap();
2209
2210 search_view
2211 .update(cx, |search_view, cx| {
2212 assert_eq!(search_view.active_match_index, Some(2));
2213 assert_eq!(
2214 search_view
2215 .results_editor
2216 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2217 [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
2218 );
2219 search_view.select_match(Direction::Prev, cx);
2220 })
2221 .unwrap();
2222
2223 search_view
2224 .update(cx, |search_view, cx| {
2225 assert_eq!(search_view.active_match_index, Some(1));
2226 assert_eq!(
2227 search_view
2228 .results_editor
2229 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2230 [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
2231 );
2232 })
2233 .unwrap();
2234 }
2235
2236 #[gpui::test]
2237 async fn test_project_search_focus(cx: &mut TestAppContext) {
2238 init_test(cx);
2239
2240 let fs = FakeFs::new(cx.background_executor.clone());
2241 fs.insert_tree(
2242 "/dir",
2243 json!({
2244 "one.rs": "const ONE: usize = 1;",
2245 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2246 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2247 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2248 }),
2249 )
2250 .await;
2251 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2252 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2253 let workspace = window.clone();
2254
2255 let active_item = cx.read(|cx| {
2256 workspace
2257 .read(cx)
2258 .unwrap()
2259 .active_pane()
2260 .read(cx)
2261 .active_item()
2262 .and_then(|item| item.downcast::<ProjectSearchView>())
2263 });
2264 assert!(
2265 active_item.is_none(),
2266 "Expected no search panel to be active"
2267 );
2268
2269 workspace
2270 .update(cx, |workspace, cx| {
2271 ProjectSearchView::deploy(workspace, &workspace::NewSearch, cx)
2272 })
2273 .unwrap();
2274
2275 let Some(search_view) = cx.read(|cx| {
2276 workspace
2277 .read(cx)
2278 .unwrap()
2279 .active_pane()
2280 .read(cx)
2281 .active_item()
2282 .and_then(|item| item.downcast::<ProjectSearchView>())
2283 }) else {
2284 panic!("Search view expected to appear after new search event trigger")
2285 };
2286
2287 cx.spawn(|mut cx| async move {
2288 window
2289 .update(&mut cx, |_, cx| {
2290 cx.dispatch_action(ToggleFocus.boxed_clone())
2291 })
2292 .unwrap();
2293 })
2294 .detach();
2295 cx.background_executor.run_until_parked();
2296
2297 window.update(cx, |_, cx| {
2298 search_view.update(cx, |search_view, cx| {
2299 assert!(
2300 search_view.query_editor.focus_handle(cx).is_focused(cx),
2301 "Empty search view should be focused after the toggle focus event: no results panel to focus on",
2302 );
2303 });
2304 }).unwrap();
2305
2306 window
2307 .update(cx, |_, cx| {
2308 search_view.update(cx, |search_view, cx| {
2309 let query_editor = &search_view.query_editor;
2310 assert!(
2311 query_editor.focus_handle(cx).is_focused(cx),
2312 "Search view should be focused after the new search view is activated",
2313 );
2314 let query_text = query_editor.read(cx).text(cx);
2315 assert!(
2316 query_text.is_empty(),
2317 "New search query should be empty but got '{query_text}'",
2318 );
2319 let results_text = search_view
2320 .results_editor
2321 .update(cx, |editor, cx| editor.display_text(cx));
2322 assert!(
2323 results_text.is_empty(),
2324 "Empty search view should have no results but got '{results_text}'"
2325 );
2326 });
2327 })
2328 .unwrap();
2329
2330 window
2331 .update(cx, |_, cx| {
2332 search_view.update(cx, |search_view, cx| {
2333 search_view.query_editor.update(cx, |query_editor, cx| {
2334 query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", cx)
2335 });
2336 search_view.search(cx);
2337 });
2338 })
2339 .unwrap();
2340
2341 cx.background_executor.run_until_parked();
2342 window
2343 .update(cx, |_, cx| {
2344 search_view.update(cx, |search_view, cx| {
2345 let results_text = search_view
2346 .results_editor
2347 .update(cx, |editor, cx| editor.display_text(cx));
2348 assert!(
2349 results_text.is_empty(),
2350 "Search view for mismatching query should have no results but got '{results_text}'"
2351 );
2352 assert!(
2353 search_view.query_editor.focus_handle(cx).is_focused(cx),
2354 "Search view should be focused after mismatching query had been used in search",
2355 );
2356 });
2357 })
2358 .unwrap();
2359 cx.spawn(|mut cx| async move {
2360 window.update(&mut cx, |_, cx| {
2361 cx.dispatch_action(ToggleFocus.boxed_clone())
2362 })
2363 })
2364 .detach();
2365 cx.background_executor.run_until_parked();
2366 window.update(cx, |_, cx| {
2367 search_view.update(cx, |search_view, cx| {
2368 assert!(
2369 search_view.query_editor.focus_handle(cx).is_focused(cx),
2370 "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
2371 );
2372 });
2373 }).unwrap();
2374
2375 window
2376 .update(cx, |_, cx| {
2377 search_view.update(cx, |search_view, cx| {
2378 search_view
2379 .query_editor
2380 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
2381 search_view.search(cx);
2382 })
2383 })
2384 .unwrap();
2385 cx.background_executor.run_until_parked();
2386 window.update(cx, |_, cx|
2387 search_view.update(cx, |search_view, cx| {
2388 assert_eq!(
2389 search_view
2390 .results_editor
2391 .update(cx, |editor, cx| editor.display_text(cx)),
2392 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2393 "Search view results should match the query"
2394 );
2395 assert!(
2396 search_view.results_editor.focus_handle(cx).is_focused(cx),
2397 "Search view with mismatching query should be focused after search results are available",
2398 );
2399 })).unwrap();
2400 cx.spawn(|mut cx| async move {
2401 window
2402 .update(&mut cx, |_, cx| {
2403 cx.dispatch_action(ToggleFocus.boxed_clone())
2404 })
2405 .unwrap();
2406 })
2407 .detach();
2408 cx.background_executor.run_until_parked();
2409 window.update(cx, |_, cx| {
2410 search_view.update(cx, |search_view, cx| {
2411 assert!(
2412 search_view.results_editor.focus_handle(cx).is_focused(cx),
2413 "Search view with matching query should still have its results editor focused after the toggle focus event",
2414 );
2415 });
2416 }).unwrap();
2417
2418 workspace
2419 .update(cx, |workspace, cx| {
2420 ProjectSearchView::deploy(workspace, &workspace::NewSearch, cx)
2421 })
2422 .unwrap();
2423 cx.background_executor.run_until_parked();
2424 let Some(search_view_2) = cx.read(|cx| {
2425 workspace
2426 .read(cx)
2427 .unwrap()
2428 .active_pane()
2429 .read(cx)
2430 .active_item()
2431 .and_then(|item| item.downcast::<ProjectSearchView>())
2432 }) else {
2433 panic!("Search view expected to appear after new search event trigger")
2434 };
2435 assert!(
2436 search_view_2 != search_view,
2437 "New search view should be open after `workspace::NewSearch` event"
2438 );
2439
2440 window.update(cx, |_, cx| {
2441 search_view.update(cx, |search_view, cx| {
2442 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO", "First search view should not have an updated query");
2443 assert_eq!(
2444 search_view
2445 .results_editor
2446 .update(cx, |editor, cx| editor.display_text(cx)),
2447 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2448 "Results of the first search view should not update too"
2449 );
2450 assert!(
2451 !search_view.query_editor.focus_handle(cx).is_focused(cx),
2452 "Focus should be moved away from the first search view"
2453 );
2454 });
2455 }).unwrap();
2456
2457 window.update(cx, |_, cx| {
2458 search_view_2.update(cx, |search_view_2, cx| {
2459 assert_eq!(
2460 search_view_2.query_editor.read(cx).text(cx),
2461 "two",
2462 "New search view should get the query from the text cursor was at during the event spawn (first search view's first result)"
2463 );
2464 assert_eq!(
2465 search_view_2
2466 .results_editor
2467 .update(cx, |editor, cx| editor.display_text(cx)),
2468 "",
2469 "No search results should be in the 2nd view yet, as we did not spawn a search for it"
2470 );
2471 assert!(
2472 search_view_2.query_editor.focus_handle(cx).is_focused(cx),
2473 "Focus should be moved into query editor fo the new window"
2474 );
2475 });
2476 }).unwrap();
2477
2478 window
2479 .update(cx, |_, cx| {
2480 search_view_2.update(cx, |search_view_2, cx| {
2481 search_view_2
2482 .query_editor
2483 .update(cx, |query_editor, cx| query_editor.set_text("FOUR", cx));
2484 search_view_2.search(cx);
2485 });
2486 })
2487 .unwrap();
2488
2489 cx.background_executor.run_until_parked();
2490 window.update(cx, |_, cx| {
2491 search_view_2.update(cx, |search_view_2, cx| {
2492 assert_eq!(
2493 search_view_2
2494 .results_editor
2495 .update(cx, |editor, cx| editor.display_text(cx)),
2496 "\n\nconst FOUR: usize = one::ONE + three::THREE;",
2497 "New search view with the updated query should have new search results"
2498 );
2499 assert!(
2500 search_view_2.results_editor.focus_handle(cx).is_focused(cx),
2501 "Search view with mismatching query should be focused after search results are available",
2502 );
2503 });
2504 }).unwrap();
2505
2506 cx.spawn(|mut cx| async move {
2507 window
2508 .update(&mut cx, |_, cx| {
2509 cx.dispatch_action(ToggleFocus.boxed_clone())
2510 })
2511 .unwrap();
2512 })
2513 .detach();
2514 cx.background_executor.run_until_parked();
2515 window.update(cx, |_, cx| {
2516 search_view_2.update(cx, |search_view_2, cx| {
2517 assert!(
2518 search_view_2.results_editor.focus_handle(cx).is_focused(cx),
2519 "Search view with matching query should switch focus to the results editor after the toggle focus event",
2520 );
2521 });}).unwrap();
2522 }
2523
2524 #[gpui::test]
2525 async fn test_new_project_search_in_directory(cx: &mut TestAppContext) {
2526 init_test(cx);
2527
2528 let fs = FakeFs::new(cx.background_executor.clone());
2529 fs.insert_tree(
2530 "/dir",
2531 json!({
2532 "a": {
2533 "one.rs": "const ONE: usize = 1;",
2534 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2535 },
2536 "b": {
2537 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2538 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2539 },
2540 }),
2541 )
2542 .await;
2543 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2544 let worktree_id = project.read_with(cx, |project, cx| {
2545 project.worktrees().next().unwrap().read(cx).id()
2546 });
2547 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2548 let workspace = window.root(cx).unwrap();
2549
2550 let active_item = cx.read(|cx| {
2551 workspace
2552 .read(cx)
2553 .active_pane()
2554 .read(cx)
2555 .active_item()
2556 .and_then(|item| item.downcast::<ProjectSearchView>())
2557 });
2558 assert!(
2559 active_item.is_none(),
2560 "Expected no search panel to be active"
2561 );
2562
2563 let one_file_entry = cx.update(|cx| {
2564 workspace
2565 .read(cx)
2566 .project()
2567 .read(cx)
2568 .entry_for_path(&(worktree_id, "a/one.rs").into(), cx)
2569 .expect("no entry for /a/one.rs file")
2570 });
2571 assert!(one_file_entry.is_file());
2572 window
2573 .update(cx, |workspace, cx| {
2574 ProjectSearchView::new_search_in_directory(workspace, &one_file_entry, cx)
2575 })
2576 .unwrap();
2577 let active_search_entry = cx.read(|cx| {
2578 workspace
2579 .read(cx)
2580 .active_pane()
2581 .read(cx)
2582 .active_item()
2583 .and_then(|item| item.downcast::<ProjectSearchView>())
2584 });
2585 assert!(
2586 active_search_entry.is_none(),
2587 "Expected no search panel to be active for file entry"
2588 );
2589
2590 let a_dir_entry = cx.update(|cx| {
2591 workspace
2592 .read(cx)
2593 .project()
2594 .read(cx)
2595 .entry_for_path(&(worktree_id, "a").into(), cx)
2596 .expect("no entry for /a/ directory")
2597 });
2598 assert!(a_dir_entry.is_dir());
2599 window
2600 .update(cx, |workspace, cx| {
2601 ProjectSearchView::new_search_in_directory(workspace, &a_dir_entry, cx)
2602 })
2603 .unwrap();
2604
2605 let Some(search_view) = cx.read(|cx| {
2606 workspace
2607 .read(cx)
2608 .active_pane()
2609 .read(cx)
2610 .active_item()
2611 .and_then(|item| item.downcast::<ProjectSearchView>())
2612 }) else {
2613 panic!("Search view expected to appear after new search in directory event trigger")
2614 };
2615 cx.background_executor.run_until_parked();
2616 window
2617 .update(cx, |_, cx| {
2618 search_view.update(cx, |search_view, cx| {
2619 assert!(
2620 search_view.query_editor.focus_handle(cx).is_focused(cx),
2621 "On new search in directory, focus should be moved into query editor"
2622 );
2623 search_view.excluded_files_editor.update(cx, |editor, cx| {
2624 assert!(
2625 editor.display_text(cx).is_empty(),
2626 "New search in directory should not have any excluded files"
2627 );
2628 });
2629 search_view.included_files_editor.update(cx, |editor, cx| {
2630 assert_eq!(
2631 editor.display_text(cx),
2632 a_dir_entry.path.to_str().unwrap(),
2633 "New search in directory should have included dir entry path"
2634 );
2635 });
2636 });
2637 })
2638 .unwrap();
2639 window
2640 .update(cx, |_, cx| {
2641 search_view.update(cx, |search_view, cx| {
2642 search_view
2643 .query_editor
2644 .update(cx, |query_editor, cx| query_editor.set_text("const", cx));
2645 search_view.search(cx);
2646 });
2647 })
2648 .unwrap();
2649 cx.background_executor.run_until_parked();
2650 window
2651 .update(cx, |_, cx| {
2652 search_view.update(cx, |search_view, cx| {
2653 assert_eq!(
2654 search_view
2655 .results_editor
2656 .update(cx, |editor, cx| editor.display_text(cx)),
2657 "\n\nconst ONE: usize = 1;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2658 "New search in directory should have a filter that matches a certain directory"
2659 );
2660 })
2661 })
2662 .unwrap();
2663 }
2664
2665 #[gpui::test]
2666 async fn test_search_query_history(cx: &mut TestAppContext) {
2667 init_test(cx);
2668
2669 let fs = FakeFs::new(cx.background_executor.clone());
2670 fs.insert_tree(
2671 "/dir",
2672 json!({
2673 "one.rs": "const ONE: usize = 1;",
2674 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2675 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2676 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2677 }),
2678 )
2679 .await;
2680 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2681 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2682 let workspace = window.root(cx).unwrap();
2683 window
2684 .update(cx, |workspace, cx| {
2685 ProjectSearchView::deploy(workspace, &workspace::NewSearch, cx)
2686 })
2687 .unwrap();
2688
2689 let search_view = cx.read(|cx| {
2690 workspace
2691 .read(cx)
2692 .active_pane()
2693 .read(cx)
2694 .active_item()
2695 .and_then(|item| item.downcast::<ProjectSearchView>())
2696 .expect("Search view expected to appear after new search event trigger")
2697 });
2698
2699 let search_bar = window.build_view(cx, |cx| {
2700 let mut search_bar = ProjectSearchBar::new();
2701 search_bar.set_active_pane_item(Some(&search_view), cx);
2702 // search_bar.show(cx);
2703 search_bar
2704 });
2705
2706 // Add 3 search items into the history + another unsubmitted one.
2707 window
2708 .update(cx, |_, cx| {
2709 search_view.update(cx, |search_view, cx| {
2710 search_view.search_options = SearchOptions::CASE_SENSITIVE;
2711 search_view
2712 .query_editor
2713 .update(cx, |query_editor, cx| query_editor.set_text("ONE", cx));
2714 search_view.search(cx);
2715 });
2716 })
2717 .unwrap();
2718
2719 cx.background_executor.run_until_parked();
2720 window
2721 .update(cx, |_, cx| {
2722 search_view.update(cx, |search_view, cx| {
2723 search_view
2724 .query_editor
2725 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
2726 search_view.search(cx);
2727 });
2728 })
2729 .unwrap();
2730 cx.background_executor.run_until_parked();
2731 window
2732 .update(cx, |_, cx| {
2733 search_view.update(cx, |search_view, cx| {
2734 search_view
2735 .query_editor
2736 .update(cx, |query_editor, cx| query_editor.set_text("THREE", cx));
2737 search_view.search(cx);
2738 })
2739 })
2740 .unwrap();
2741 cx.background_executor.run_until_parked();
2742 window
2743 .update(cx, |_, cx| {
2744 search_view.update(cx, |search_view, cx| {
2745 search_view.query_editor.update(cx, |query_editor, cx| {
2746 query_editor.set_text("JUST_TEXT_INPUT", cx)
2747 });
2748 })
2749 })
2750 .unwrap();
2751 cx.background_executor.run_until_parked();
2752
2753 // Ensure that the latest input with search settings is active.
2754 window
2755 .update(cx, |_, cx| {
2756 search_view.update(cx, |search_view, cx| {
2757 assert_eq!(
2758 search_view.query_editor.read(cx).text(cx),
2759 "JUST_TEXT_INPUT"
2760 );
2761 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2762 });
2763 })
2764 .unwrap();
2765
2766 // Next history query after the latest should set the query to the empty string.
2767 window
2768 .update(cx, |_, cx| {
2769 search_bar.update(cx, |search_bar, cx| {
2770 search_bar.next_history_query(&NextHistoryQuery, cx);
2771 })
2772 })
2773 .unwrap();
2774 window
2775 .update(cx, |_, cx| {
2776 search_view.update(cx, |search_view, cx| {
2777 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
2778 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2779 });
2780 })
2781 .unwrap();
2782 window
2783 .update(cx, |_, cx| {
2784 search_bar.update(cx, |search_bar, cx| {
2785 search_bar.next_history_query(&NextHistoryQuery, cx);
2786 })
2787 })
2788 .unwrap();
2789 window
2790 .update(cx, |_, cx| {
2791 search_view.update(cx, |search_view, cx| {
2792 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
2793 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2794 });
2795 })
2796 .unwrap();
2797
2798 // First previous query for empty current query should set the query to the latest submitted one.
2799 window
2800 .update(cx, |_, cx| {
2801 search_bar.update(cx, |search_bar, cx| {
2802 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2803 });
2804 })
2805 .unwrap();
2806 window
2807 .update(cx, |_, cx| {
2808 search_view.update(cx, |search_view, cx| {
2809 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
2810 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2811 });
2812 })
2813 .unwrap();
2814
2815 // Further previous items should go over the history in reverse order.
2816 window
2817 .update(cx, |_, cx| {
2818 search_bar.update(cx, |search_bar, cx| {
2819 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2820 });
2821 })
2822 .unwrap();
2823 window
2824 .update(cx, |_, cx| {
2825 search_view.update(cx, |search_view, cx| {
2826 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
2827 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2828 });
2829 })
2830 .unwrap();
2831
2832 // Previous items should never go behind the first history item.
2833 window
2834 .update(cx, |_, cx| {
2835 search_bar.update(cx, |search_bar, cx| {
2836 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2837 });
2838 })
2839 .unwrap();
2840 window
2841 .update(cx, |_, cx| {
2842 search_view.update(cx, |search_view, cx| {
2843 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
2844 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2845 });
2846 })
2847 .unwrap();
2848 window
2849 .update(cx, |_, cx| {
2850 search_bar.update(cx, |search_bar, cx| {
2851 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2852 });
2853 })
2854 .unwrap();
2855 window
2856 .update(cx, |_, cx| {
2857 search_view.update(cx, |search_view, cx| {
2858 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
2859 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2860 });
2861 })
2862 .unwrap();
2863
2864 // Next items should go over the history in the original order.
2865 window
2866 .update(cx, |_, cx| {
2867 search_bar.update(cx, |search_bar, cx| {
2868 search_bar.next_history_query(&NextHistoryQuery, cx);
2869 });
2870 })
2871 .unwrap();
2872 window
2873 .update(cx, |_, cx| {
2874 search_view.update(cx, |search_view, cx| {
2875 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
2876 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2877 });
2878 })
2879 .unwrap();
2880
2881 window
2882 .update(cx, |_, cx| {
2883 search_view.update(cx, |search_view, cx| {
2884 search_view
2885 .query_editor
2886 .update(cx, |query_editor, cx| query_editor.set_text("TWO_NEW", cx));
2887 search_view.search(cx);
2888 });
2889 })
2890 .unwrap();
2891 cx.background_executor.run_until_parked();
2892 window
2893 .update(cx, |_, cx| {
2894 search_view.update(cx, |search_view, cx| {
2895 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
2896 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2897 });
2898 })
2899 .unwrap();
2900
2901 // New search input should add another entry to history and move the selection to the end of the history.
2902 window
2903 .update(cx, |_, cx| {
2904 search_bar.update(cx, |search_bar, cx| {
2905 search_bar.previous_history_query(&PreviousHistoryQuery, 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), "THREE");
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.previous_history_query(&PreviousHistoryQuery, 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), "TWO");
2928 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2929 });
2930 })
2931 .unwrap();
2932 window
2933 .update(cx, |_, cx| {
2934 search_bar.update(cx, |search_bar, cx| {
2935 search_bar.next_history_query(&NextHistoryQuery, cx);
2936 });
2937 })
2938 .unwrap();
2939 window
2940 .update(cx, |_, cx| {
2941 search_view.update(cx, |search_view, cx| {
2942 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
2943 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2944 });
2945 })
2946 .unwrap();
2947 window
2948 .update(cx, |_, cx| {
2949 search_bar.update(cx, |search_bar, cx| {
2950 search_bar.next_history_query(&NextHistoryQuery, cx);
2951 });
2952 })
2953 .unwrap();
2954 window
2955 .update(cx, |_, cx| {
2956 search_view.update(cx, |search_view, cx| {
2957 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
2958 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2959 });
2960 })
2961 .unwrap();
2962 window
2963 .update(cx, |_, cx| {
2964 search_bar.update(cx, |search_bar, cx| {
2965 search_bar.next_history_query(&NextHistoryQuery, cx);
2966 });
2967 })
2968 .unwrap();
2969 window
2970 .update(cx, |_, cx| {
2971 search_view.update(cx, |search_view, cx| {
2972 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
2973 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2974 });
2975 })
2976 .unwrap();
2977 }
2978
2979 pub fn init_test(cx: &mut TestAppContext) {
2980 cx.update(|cx| {
2981 let settings = SettingsStore::test(cx);
2982 cx.set_global(settings);
2983 cx.set_global(ActiveSearches::default());
2984 SemanticIndexSettings::register(cx);
2985
2986 theme::init(theme::LoadThemes::JustBase, cx);
2987
2988 language::init(cx);
2989 client::init_settings(cx);
2990 editor::init(cx);
2991 workspace::init_settings(cx);
2992 Project::init_settings(cx);
2993 super::init(cx);
2994 });
2995 }
2996}