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