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