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
922 pub fn new_search_in_directory(
923 workspace: &mut Workspace,
924 dir_entry: &Entry,
925 cx: &mut ViewContext<Workspace>,
926 ) {
927 if !dir_entry.is_dir() {
928 return;
929 }
930 let Some(filter_str) = dir_entry.path.to_str() else {
931 return;
932 };
933
934 let model = cx.build_model(|cx| ProjectSearch::new(workspace.project().clone(), cx));
935 let search = cx.build_view(|cx| ProjectSearchView::new(model, cx, None));
936 workspace.add_item(Box::new(search.clone()), cx);
937 search.update(cx, |search, cx| {
938 search
939 .included_files_editor
940 .update(cx, |editor, cx| editor.set_text(filter_str, cx));
941 search.filters_enabled = true;
942 search.focus_query_editor(cx)
943 });
944 }
945
946 // Add another search tab to the workspace.
947 fn deploy(
948 workspace: &mut Workspace,
949 _: &workspace::NewSearch,
950 cx: &mut ViewContext<Workspace>,
951 ) {
952 // Clean up entries for dropped projects
953 cx.update_global(|state: &mut ActiveSearches, _cx| {
954 state.0.retain(|project, _| project.is_upgradable())
955 });
956
957 let query = workspace.active_item(cx).and_then(|item| {
958 let editor = item.act_as::<Editor>(cx)?;
959 let query = editor.query_suggestion(cx);
960 if query.is_empty() {
961 None
962 } else {
963 Some(query)
964 }
965 });
966
967 let settings = cx
968 .global::<ActiveSettings>()
969 .0
970 .get(&workspace.project().downgrade());
971
972 let settings = if let Some(settings) = settings {
973 Some(settings.clone())
974 } else {
975 None
976 };
977
978 let model = cx.build_model(|cx| ProjectSearch::new(workspace.project().clone(), cx));
979 let search = cx.build_view(|cx| ProjectSearchView::new(model, cx, settings));
980
981 workspace.add_item(Box::new(search.clone()), cx);
982
983 search.update(cx, |search, cx| {
984 if let Some(query) = query {
985 search.set_query(&query, cx);
986 }
987 search.focus_query_editor(cx)
988 });
989 }
990
991 fn search(&mut self, cx: &mut ViewContext<Self>) {
992 let mode = self.current_mode;
993 match mode {
994 SearchMode::Semantic => {
995 if self.semantic_state.is_some() {
996 if let Some(query) = self.build_search_query(cx) {
997 self.model
998 .update(cx, |model, cx| model.semantic_search(query.as_inner(), cx));
999 }
1000 }
1001 }
1002
1003 _ => {
1004 if let Some(query) = self.build_search_query(cx) {
1005 self.model.update(cx, |model, cx| model.search(query, cx));
1006 }
1007 }
1008 }
1009 }
1010
1011 fn build_search_query(&mut self, cx: &mut ViewContext<Self>) -> Option<SearchQuery> {
1012 let text = self.query_editor.read(cx).text(cx);
1013 let included_files =
1014 match Self::parse_path_matches(&self.included_files_editor.read(cx).text(cx)) {
1015 Ok(included_files) => {
1016 self.panels_with_errors.remove(&InputPanel::Include);
1017 included_files
1018 }
1019 Err(_e) => {
1020 self.panels_with_errors.insert(InputPanel::Include);
1021 cx.notify();
1022 return None;
1023 }
1024 };
1025 let excluded_files =
1026 match Self::parse_path_matches(&self.excluded_files_editor.read(cx).text(cx)) {
1027 Ok(excluded_files) => {
1028 self.panels_with_errors.remove(&InputPanel::Exclude);
1029 excluded_files
1030 }
1031 Err(_e) => {
1032 self.panels_with_errors.insert(InputPanel::Exclude);
1033 cx.notify();
1034 return None;
1035 }
1036 };
1037 let current_mode = self.current_mode;
1038 match current_mode {
1039 SearchMode::Regex => {
1040 match SearchQuery::regex(
1041 text,
1042 self.search_options.contains(SearchOptions::WHOLE_WORD),
1043 self.search_options.contains(SearchOptions::CASE_SENSITIVE),
1044 self.search_options.contains(SearchOptions::INCLUDE_IGNORED),
1045 included_files,
1046 excluded_files,
1047 ) {
1048 Ok(query) => {
1049 self.panels_with_errors.remove(&InputPanel::Query);
1050 Some(query)
1051 }
1052 Err(_e) => {
1053 self.panels_with_errors.insert(InputPanel::Query);
1054 cx.notify();
1055 None
1056 }
1057 }
1058 }
1059 _ => match SearchQuery::text(
1060 text,
1061 self.search_options.contains(SearchOptions::WHOLE_WORD),
1062 self.search_options.contains(SearchOptions::CASE_SENSITIVE),
1063 self.search_options.contains(SearchOptions::INCLUDE_IGNORED),
1064 included_files,
1065 excluded_files,
1066 ) {
1067 Ok(query) => {
1068 self.panels_with_errors.remove(&InputPanel::Query);
1069 Some(query)
1070 }
1071 Err(_e) => {
1072 self.panels_with_errors.insert(InputPanel::Query);
1073 cx.notify();
1074 None
1075 }
1076 },
1077 }
1078 }
1079
1080 fn parse_path_matches(text: &str) -> anyhow::Result<Vec<PathMatcher>> {
1081 text.split(',')
1082 .map(str::trim)
1083 .filter(|maybe_glob_str| !maybe_glob_str.is_empty())
1084 .map(|maybe_glob_str| {
1085 PathMatcher::new(maybe_glob_str)
1086 .with_context(|| format!("parsing {maybe_glob_str} as path matcher"))
1087 })
1088 .collect()
1089 }
1090
1091 fn select_match(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
1092 if let Some(index) = self.active_match_index {
1093 let match_ranges = self.model.read(cx).match_ranges.clone();
1094 let new_index = self.results_editor.update(cx, |editor, cx| {
1095 editor.match_index_for_direction(&match_ranges, index, direction, 1, cx)
1096 });
1097
1098 let range_to_select = match_ranges[new_index].clone();
1099 self.results_editor.update(cx, |editor, cx| {
1100 let range_to_select = editor.range_for_match(&range_to_select);
1101 editor.unfold_ranges([range_to_select.clone()], false, true, cx);
1102 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
1103 s.select_ranges([range_to_select])
1104 });
1105 });
1106 }
1107 }
1108
1109 fn focus_query_editor(&mut self, cx: &mut ViewContext<Self>) {
1110 self.query_editor.update(cx, |query_editor, cx| {
1111 query_editor.select_all(&SelectAll, cx);
1112 });
1113 self.query_editor_was_focused = true;
1114 let editor_handle = self.query_editor.focus_handle(cx);
1115 cx.focus(&editor_handle);
1116 }
1117
1118 fn set_query(&mut self, query: &str, cx: &mut ViewContext<Self>) {
1119 self.query_editor
1120 .update(cx, |query_editor, cx| query_editor.set_text(query, cx));
1121 }
1122
1123 fn focus_results_editor(&mut self, cx: &mut ViewContext<Self>) {
1124 self.query_editor.update(cx, |query_editor, cx| {
1125 let cursor = query_editor.selections.newest_anchor().head();
1126 query_editor.change_selections(None, cx, |s| s.select_ranges([cursor.clone()..cursor]));
1127 });
1128 self.query_editor_was_focused = false;
1129 let results_handle = self.results_editor.focus_handle(cx);
1130 cx.focus(&results_handle);
1131 }
1132
1133 fn model_changed(&mut self, cx: &mut ViewContext<Self>) {
1134 let match_ranges = self.model.read(cx).match_ranges.clone();
1135 if match_ranges.is_empty() {
1136 self.active_match_index = None;
1137 } else {
1138 self.active_match_index = Some(0);
1139 self.update_match_index(cx);
1140 let prev_search_id = mem::replace(&mut self.search_id, self.model.read(cx).search_id);
1141 let is_new_search = self.search_id != prev_search_id;
1142 self.results_editor.update(cx, |editor, cx| {
1143 if is_new_search {
1144 let range_to_select = match_ranges
1145 .first()
1146 .clone()
1147 .map(|range| editor.range_for_match(range));
1148 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
1149 s.select_ranges(range_to_select)
1150 });
1151 }
1152 editor.highlight_background::<Self>(
1153 match_ranges,
1154 |theme| theme.search_match_background,
1155 cx,
1156 );
1157 });
1158 if is_new_search && self.query_editor.focus_handle(cx).is_focused(cx) {
1159 self.focus_results_editor(cx);
1160 }
1161 }
1162
1163 cx.emit(ViewEvent::UpdateTab);
1164 cx.notify();
1165 }
1166
1167 fn update_match_index(&mut self, cx: &mut ViewContext<Self>) {
1168 let results_editor = self.results_editor.read(cx);
1169 let new_index = active_match_index(
1170 &self.model.read(cx).match_ranges,
1171 &results_editor.selections.newest_anchor().head(),
1172 &results_editor.buffer().read(cx).snapshot(cx),
1173 );
1174 if self.active_match_index != new_index {
1175 self.active_match_index = new_index;
1176 cx.notify();
1177 }
1178 }
1179
1180 pub fn has_matches(&self) -> bool {
1181 self.active_match_index.is_some()
1182 }
1183
1184 fn landing_text_minor(&self) -> SharedString {
1185 match self.current_mode {
1186 SearchMode::Text | SearchMode::Regex => "Include/exclude specific paths with the filter option. Matching exact word and/or casing is available too.".into(),
1187 SearchMode::Semantic => "\nSimply explain the code you are looking to find. ex. 'prompt user for permissions to index their project'".into()
1188 }
1189 }
1190}
1191
1192impl Default for ProjectSearchBar {
1193 fn default() -> Self {
1194 Self::new()
1195 }
1196}
1197
1198impl ProjectSearchBar {
1199 pub fn new() -> Self {
1200 Self {
1201 active_project_search: Default::default(),
1202 subscription: Default::default(),
1203 }
1204 }
1205
1206 fn cycle_mode(&self, _: &CycleMode, cx: &mut ViewContext<Self>) {
1207 if let Some(view) = self.active_project_search.as_ref() {
1208 view.update(cx, |this, cx| {
1209 let new_mode =
1210 crate::mode::next_mode(&this.current_mode, SemanticIndex::enabled(cx));
1211 this.activate_search_mode(new_mode, cx);
1212 let editor_handle = this.query_editor.focus_handle(cx);
1213 cx.focus(&editor_handle);
1214 });
1215 }
1216 }
1217
1218 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
1219 if let Some(search_view) = self.active_project_search.as_ref() {
1220 search_view.update(cx, |search_view, cx| {
1221 if !search_view
1222 .replacement_editor
1223 .focus_handle(cx)
1224 .is_focused(cx)
1225 {
1226 cx.stop_propagation();
1227 search_view.search(cx);
1228 }
1229 });
1230 }
1231 }
1232
1233 fn search_in_new(workspace: &mut Workspace, _: &SearchInNew, cx: &mut ViewContext<Workspace>) {
1234 if let Some(search_view) = workspace
1235 .active_item(cx)
1236 .and_then(|item| item.downcast::<ProjectSearchView>())
1237 {
1238 let new_query = search_view.update(cx, |search_view, cx| {
1239 let new_query = search_view.build_search_query(cx);
1240 if new_query.is_some() {
1241 if let Some(old_query) = search_view.model.read(cx).active_query.clone() {
1242 search_view.query_editor.update(cx, |editor, cx| {
1243 editor.set_text(old_query.as_str(), cx);
1244 });
1245 search_view.search_options = SearchOptions::from_query(&old_query);
1246 }
1247 }
1248 new_query
1249 });
1250 if let Some(new_query) = new_query {
1251 let model = cx.build_model(|cx| {
1252 let mut model = ProjectSearch::new(workspace.project().clone(), cx);
1253 model.search(new_query, cx);
1254 model
1255 });
1256 workspace.add_item(
1257 Box::new(cx.build_view(|cx| ProjectSearchView::new(model, cx, None))),
1258 cx,
1259 );
1260 }
1261 }
1262 }
1263
1264 fn tab(&mut self, _: &editor::Tab, cx: &mut ViewContext<Self>) {
1265 self.cycle_field(Direction::Next, cx);
1266 }
1267
1268 fn tab_previous(&mut self, _: &editor::TabPrev, cx: &mut ViewContext<Self>) {
1269 self.cycle_field(Direction::Prev, cx);
1270 }
1271
1272 fn cycle_field(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
1273 let active_project_search = match &self.active_project_search {
1274 Some(active_project_search) => active_project_search,
1275
1276 None => {
1277 return;
1278 }
1279 };
1280
1281 active_project_search.update(cx, |project_view, cx| {
1282 let mut views = vec![&project_view.query_editor];
1283 if project_view.filters_enabled {
1284 views.extend([
1285 &project_view.included_files_editor,
1286 &project_view.excluded_files_editor,
1287 ]);
1288 }
1289 if project_view.replace_enabled {
1290 views.push(&project_view.replacement_editor);
1291 }
1292 let current_index = match views
1293 .iter()
1294 .enumerate()
1295 .find(|(_, view)| view.focus_handle(cx).is_focused(cx))
1296 {
1297 Some((index, _)) => index,
1298
1299 None => {
1300 return;
1301 }
1302 };
1303
1304 let new_index = match direction {
1305 Direction::Next => (current_index + 1) % views.len(),
1306 Direction::Prev if current_index == 0 => views.len() - 1,
1307 Direction::Prev => (current_index - 1) % views.len(),
1308 };
1309 let next_focus_handle = views[new_index].focus_handle(cx);
1310 cx.focus(&next_focus_handle);
1311 cx.stop_propagation();
1312 });
1313 }
1314
1315 fn toggle_search_option(&mut self, option: SearchOptions, cx: &mut ViewContext<Self>) -> bool {
1316 if let Some(search_view) = self.active_project_search.as_ref() {
1317 search_view.update(cx, |search_view, cx| {
1318 search_view.toggle_search_option(option, cx);
1319 search_view.search(cx);
1320 });
1321
1322 cx.notify();
1323 true
1324 } else {
1325 false
1326 }
1327 }
1328
1329 fn toggle_replace(&mut self, _: &ToggleReplace, cx: &mut ViewContext<Self>) {
1330 if let Some(search) = &self.active_project_search {
1331 search.update(cx, |this, cx| {
1332 this.replace_enabled = !this.replace_enabled;
1333 let editor_to_focus = if !this.replace_enabled {
1334 this.query_editor.focus_handle(cx)
1335 } else {
1336 this.replacement_editor.focus_handle(cx)
1337 };
1338 cx.focus(&editor_to_focus);
1339 cx.notify();
1340 });
1341 }
1342 }
1343
1344 fn toggle_filters(&mut self, cx: &mut ViewContext<Self>) -> bool {
1345 if let Some(search_view) = self.active_project_search.as_ref() {
1346 search_view.update(cx, |search_view, cx| {
1347 search_view.toggle_filters(cx);
1348 search_view
1349 .included_files_editor
1350 .update(cx, |_, cx| cx.notify());
1351 search_view
1352 .excluded_files_editor
1353 .update(cx, |_, cx| cx.notify());
1354 cx.refresh();
1355 cx.notify();
1356 });
1357 cx.notify();
1358 true
1359 } else {
1360 false
1361 }
1362 }
1363
1364 fn activate_search_mode(&self, mode: SearchMode, cx: &mut ViewContext<Self>) {
1365 // Update Current Mode
1366 if let Some(search_view) = self.active_project_search.as_ref() {
1367 search_view.update(cx, |search_view, cx| {
1368 search_view.activate_search_mode(mode, cx);
1369 });
1370 cx.notify();
1371 }
1372 }
1373
1374 fn is_option_enabled(&self, option: SearchOptions, cx: &AppContext) -> bool {
1375 if let Some(search) = self.active_project_search.as_ref() {
1376 search.read(cx).search_options.contains(option)
1377 } else {
1378 false
1379 }
1380 }
1381
1382 fn next_history_query(&mut self, _: &NextHistoryQuery, cx: &mut ViewContext<Self>) {
1383 if let Some(search_view) = self.active_project_search.as_ref() {
1384 search_view.update(cx, |search_view, cx| {
1385 let new_query = search_view.model.update(cx, |model, _| {
1386 if let Some(new_query) = model.search_history.next().map(str::to_string) {
1387 new_query
1388 } else {
1389 model.search_history.reset_selection();
1390 String::new()
1391 }
1392 });
1393 search_view.set_query(&new_query, cx);
1394 });
1395 }
1396 }
1397
1398 fn previous_history_query(&mut self, _: &PreviousHistoryQuery, cx: &mut ViewContext<Self>) {
1399 if let Some(search_view) = self.active_project_search.as_ref() {
1400 search_view.update(cx, |search_view, cx| {
1401 if search_view.query_editor.read(cx).text(cx).is_empty() {
1402 if let Some(new_query) = search_view
1403 .model
1404 .read(cx)
1405 .search_history
1406 .current()
1407 .map(str::to_string)
1408 {
1409 search_view.set_query(&new_query, cx);
1410 return;
1411 }
1412 }
1413
1414 if let Some(new_query) = search_view.model.update(cx, |model, _| {
1415 model.search_history.previous().map(str::to_string)
1416 }) {
1417 search_view.set_query(&new_query, cx);
1418 }
1419 });
1420 }
1421 }
1422
1423 fn new_placeholder_text(&self, cx: &mut ViewContext<Self>) -> Option<String> {
1424 let previous_query_keystrokes = cx
1425 .bindings_for_action(&PreviousHistoryQuery {})
1426 .into_iter()
1427 .next()
1428 .map(|binding| {
1429 binding
1430 .keystrokes()
1431 .iter()
1432 .map(|k| k.to_string())
1433 .collect::<Vec<_>>()
1434 });
1435 let next_query_keystrokes = cx
1436 .bindings_for_action(&NextHistoryQuery {})
1437 .into_iter()
1438 .next()
1439 .map(|binding| {
1440 binding
1441 .keystrokes()
1442 .iter()
1443 .map(|k| k.to_string())
1444 .collect::<Vec<_>>()
1445 });
1446 let new_placeholder_text = match (previous_query_keystrokes, next_query_keystrokes) {
1447 (Some(previous_query_keystrokes), Some(next_query_keystrokes)) => Some(format!(
1448 "Search ({}/{} for previous/next query)",
1449 previous_query_keystrokes.join(" "),
1450 next_query_keystrokes.join(" ")
1451 )),
1452 (None, Some(next_query_keystrokes)) => Some(format!(
1453 "Search ({} for next query)",
1454 next_query_keystrokes.join(" ")
1455 )),
1456 (Some(previous_query_keystrokes), None) => Some(format!(
1457 "Search ({} for previous query)",
1458 previous_query_keystrokes.join(" ")
1459 )),
1460 (None, None) => None,
1461 };
1462 new_placeholder_text
1463 }
1464
1465 fn render_text_input(&self, editor: &View<Editor>, cx: &ViewContext<Self>) -> impl IntoElement {
1466 let settings = ThemeSettings::get_global(cx);
1467 let text_style = TextStyle {
1468 color: if editor.read(cx).read_only() {
1469 cx.theme().colors().text_disabled
1470 } else {
1471 cx.theme().colors().text
1472 },
1473 font_family: settings.ui_font.family.clone(),
1474 font_features: settings.ui_font.features,
1475 font_size: rems(0.875).into(),
1476 font_weight: FontWeight::NORMAL,
1477 font_style: FontStyle::Normal,
1478 line_height: relative(1.3).into(),
1479 background_color: None,
1480 underline: None,
1481 white_space: WhiteSpace::Normal,
1482 };
1483
1484 EditorElement::new(
1485 &editor,
1486 EditorStyle {
1487 background: cx.theme().colors().editor_background,
1488 local_player: cx.theme().players().local(),
1489 text: text_style,
1490 ..Default::default()
1491 },
1492 )
1493 }
1494}
1495
1496impl Render for ProjectSearchBar {
1497 type Element = Div;
1498
1499 fn render(&mut self, cx: &mut ViewContext<Self>) -> Self::Element {
1500 let Some(search) = self.active_project_search.clone() else {
1501 return div();
1502 };
1503 let mut key_context = KeyContext::default();
1504 key_context.add("ProjectSearchBar");
1505 if let Some(placeholder_text) = self.new_placeholder_text(cx) {
1506 search.update(cx, |search, cx| {
1507 search.query_editor.update(cx, |this, cx| {
1508 this.set_placeholder_text(placeholder_text, cx)
1509 })
1510 });
1511 }
1512 let search = search.read(cx);
1513 let semantic_is_available = SemanticIndex::enabled(cx);
1514 let query_column = v_stack().child(
1515 h_stack()
1516 .min_w(rems(512. / 16.))
1517 .px_2()
1518 .py_1()
1519 .gap_2()
1520 .bg(cx.theme().colors().editor_background)
1521 .border_1()
1522 .border_color(cx.theme().colors().border)
1523 .rounded_lg()
1524 .on_action(cx.listener(|this, action, cx| this.confirm(action, cx)))
1525 .on_action(cx.listener(|this, action, cx| this.previous_history_query(action, cx)))
1526 .on_action(cx.listener(|this, action, cx| this.next_history_query(action, cx)))
1527 .child(IconElement::new(Icon::MagnifyingGlass))
1528 .child(self.render_text_input(&search.query_editor, cx))
1529 .child(
1530 h_stack()
1531 .child(
1532 IconButton::new("project-search-filter-button", Icon::Filter)
1533 .tooltip(|cx| {
1534 Tooltip::for_action("Toggle filters", &ToggleFilters, cx)
1535 })
1536 .on_click(cx.listener(|this, _, cx| {
1537 this.toggle_filters(cx);
1538 }))
1539 .selected(
1540 self.active_project_search
1541 .as_ref()
1542 .map(|search| search.read(cx).filters_enabled)
1543 .unwrap_or_default(),
1544 ),
1545 )
1546 .when(search.current_mode != SearchMode::Semantic, |this| {
1547 this.child(
1548 IconButton::new(
1549 "project-search-case-sensitive",
1550 Icon::CaseSensitive,
1551 )
1552 .tooltip(|cx| {
1553 Tooltip::for_action(
1554 "Toggle case sensitive",
1555 &ToggleCaseSensitive,
1556 cx,
1557 )
1558 })
1559 .selected(self.is_option_enabled(SearchOptions::CASE_SENSITIVE, cx))
1560 .on_click(cx.listener(
1561 |this, _, cx| {
1562 this.toggle_search_option(
1563 SearchOptions::CASE_SENSITIVE,
1564 cx,
1565 );
1566 },
1567 )),
1568 )
1569 .child(
1570 IconButton::new("project-search-whole-word", Icon::WholeWord)
1571 .tooltip(|cx| {
1572 Tooltip::for_action(
1573 "Toggle whole word",
1574 &ToggleWholeWord,
1575 cx,
1576 )
1577 })
1578 .selected(self.is_option_enabled(SearchOptions::WHOLE_WORD, cx))
1579 .on_click(cx.listener(|this, _, cx| {
1580 this.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
1581 })),
1582 )
1583 }),
1584 ),
1585 );
1586
1587 let mode_column = v_stack().items_start().justify_start().child(
1588 h_stack()
1589 .child(
1590 h_stack()
1591 .child(
1592 Button::new("project-search-text-button", "Text")
1593 .selected(search.current_mode == SearchMode::Text)
1594 .on_click(cx.listener(|this, _, cx| {
1595 this.activate_search_mode(SearchMode::Text, cx)
1596 }))
1597 .tooltip(|cx| {
1598 Tooltip::for_action("Toggle text search", &ActivateTextMode, cx)
1599 }),
1600 )
1601 .child(
1602 Button::new("project-search-regex-button", "Regex")
1603 .selected(search.current_mode == SearchMode::Regex)
1604 .on_click(cx.listener(|this, _, cx| {
1605 this.activate_search_mode(SearchMode::Regex, cx)
1606 }))
1607 .tooltip(|cx| {
1608 Tooltip::for_action(
1609 "Toggle regular expression search",
1610 &ActivateRegexMode,
1611 cx,
1612 )
1613 }),
1614 )
1615 .when(semantic_is_available, |this| {
1616 this.child(
1617 Button::new("project-search-semantic-button", "Semantic")
1618 .selected(search.current_mode == SearchMode::Semantic)
1619 .on_click(cx.listener(|this, _, cx| {
1620 this.activate_search_mode(SearchMode::Semantic, cx)
1621 }))
1622 .tooltip(|cx| {
1623 Tooltip::for_action(
1624 "Toggle semantic search",
1625 &ActivateSemanticMode,
1626 cx,
1627 )
1628 }),
1629 )
1630 }),
1631 )
1632 .child(
1633 IconButton::new("project-search-toggle-replace", Icon::Replace)
1634 .on_click(cx.listener(|this, _, cx| {
1635 this.toggle_replace(&ToggleReplace, cx);
1636 }))
1637 .tooltip(|cx| Tooltip::for_action("Toggle replace", &ToggleReplace, cx)),
1638 ),
1639 );
1640 let replace_column = if search.replace_enabled {
1641 h_stack()
1642 .flex_1()
1643 .h_full()
1644 .gap_2()
1645 .px_2()
1646 .py_1()
1647 .border_1()
1648 .border_color(cx.theme().colors().border)
1649 .rounded_lg()
1650 .child(IconElement::new(Icon::Replace).size(ui::IconSize::Small))
1651 .child(self.render_text_input(&search.replacement_editor, cx))
1652 } else {
1653 // Fill out the space if we don't have a replacement editor.
1654 h_stack().flex_1()
1655 };
1656 let actions_column = h_stack()
1657 .when(search.replace_enabled, |this| {
1658 this.child(
1659 IconButton::new("project-search-replace-next", Icon::ReplaceNext)
1660 .on_click(cx.listener(|this, _, cx| {
1661 if let Some(search) = this.active_project_search.as_ref() {
1662 search.update(cx, |this, cx| {
1663 this.replace_next(&ReplaceNext, cx);
1664 })
1665 }
1666 }))
1667 .tooltip(|cx| Tooltip::for_action("Replace next match", &ReplaceNext, cx)),
1668 )
1669 .child(
1670 IconButton::new("project-search-replace-all", Icon::ReplaceAll)
1671 .on_click(cx.listener(|this, _, cx| {
1672 if let Some(search) = this.active_project_search.as_ref() {
1673 search.update(cx, |this, cx| {
1674 this.replace_all(&ReplaceAll, cx);
1675 })
1676 }
1677 }))
1678 .tooltip(|cx| Tooltip::for_action("Replace all matches", &ReplaceAll, cx)),
1679 )
1680 })
1681 .when_some(search.active_match_index, |mut this, index| {
1682 let index = index + 1;
1683 let match_quantity = search.model.read(cx).match_ranges.len();
1684 if match_quantity > 0 {
1685 debug_assert!(match_quantity >= index);
1686 this = this.child(Label::new(format!("{index}/{match_quantity}")))
1687 }
1688 this
1689 })
1690 .child(
1691 IconButton::new("project-search-prev-match", Icon::ChevronLeft)
1692 .disabled(search.active_match_index.is_none())
1693 .on_click(cx.listener(|this, _, cx| {
1694 if let Some(search) = this.active_project_search.as_ref() {
1695 search.update(cx, |this, cx| {
1696 this.select_match(Direction::Prev, cx);
1697 })
1698 }
1699 }))
1700 .tooltip(|cx| {
1701 Tooltip::for_action("Go to previous match", &SelectPrevMatch, cx)
1702 }),
1703 )
1704 .child(
1705 IconButton::new("project-search-next-match", Icon::ChevronRight)
1706 .disabled(search.active_match_index.is_none())
1707 .on_click(cx.listener(|this, _, cx| {
1708 if let Some(search) = this.active_project_search.as_ref() {
1709 search.update(cx, |this, cx| {
1710 this.select_match(Direction::Next, cx);
1711 })
1712 }
1713 }))
1714 .tooltip(|cx| Tooltip::for_action("Go to next match", &SelectNextMatch, cx)),
1715 );
1716
1717 v_stack()
1718 .key_context(key_context)
1719 .flex_grow()
1720 .gap_2()
1721 .on_action(cx.listener(|this, _: &ToggleFilters, cx| {
1722 this.toggle_filters(cx);
1723 }))
1724 .on_action(cx.listener(|this, _: &ActivateTextMode, cx| {
1725 this.activate_search_mode(SearchMode::Text, cx)
1726 }))
1727 .on_action(cx.listener(|this, _: &ActivateRegexMode, cx| {
1728 this.activate_search_mode(SearchMode::Regex, cx)
1729 }))
1730 .on_action(cx.listener(|this, _: &ActivateSemanticMode, cx| {
1731 this.activate_search_mode(SearchMode::Semantic, cx)
1732 }))
1733 .on_action(cx.listener(|this, action, cx| {
1734 this.tab(action, cx);
1735 }))
1736 .on_action(cx.listener(|this, action, cx| {
1737 this.tab_previous(action, cx);
1738 }))
1739 .on_action(cx.listener(|this, action, cx| {
1740 this.cycle_mode(action, cx);
1741 }))
1742 .when(search.current_mode != SearchMode::Semantic, |this| {
1743 this.on_action(cx.listener(|this, action, cx| {
1744 this.toggle_replace(action, cx);
1745 }))
1746 .on_action(cx.listener(|this, _: &ToggleWholeWord, cx| {
1747 this.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
1748 }))
1749 .on_action(cx.listener(|this, _: &ToggleCaseSensitive, cx| {
1750 this.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
1751 }))
1752 .on_action(cx.listener(|this, action, cx| {
1753 if let Some(search) = this.active_project_search.as_ref() {
1754 search.update(cx, |this, cx| {
1755 this.replace_next(action, cx);
1756 })
1757 }
1758 }))
1759 .on_action(cx.listener(|this, action, cx| {
1760 if let Some(search) = this.active_project_search.as_ref() {
1761 search.update(cx, |this, cx| {
1762 this.replace_all(action, cx);
1763 })
1764 }
1765 }))
1766 .when(search.filters_enabled, |this| {
1767 this.on_action(cx.listener(|this, _: &ToggleIncludeIgnored, cx| {
1768 this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, cx);
1769 }))
1770 })
1771 })
1772 .child(
1773 h_stack()
1774 .justify_between()
1775 .child(query_column)
1776 .child(mode_column)
1777 .child(replace_column)
1778 .child(actions_column),
1779 )
1780 .when(search.filters_enabled, |this| {
1781 this.child(
1782 h_stack()
1783 .flex_1()
1784 .gap_2()
1785 .justify_between()
1786 .child(
1787 h_stack()
1788 .flex_1()
1789 .h_full()
1790 .px_2()
1791 .py_1()
1792 .border_1()
1793 .border_color(cx.theme().colors().border)
1794 .rounded_lg()
1795 .child(self.render_text_input(&search.included_files_editor, cx))
1796 .when(search.current_mode != SearchMode::Semantic, |this| {
1797 this.child(
1798 SearchOptions::INCLUDE_IGNORED.as_button(
1799 search
1800 .search_options
1801 .contains(SearchOptions::INCLUDE_IGNORED),
1802 cx.listener(|this, _, cx| {
1803 this.toggle_search_option(
1804 SearchOptions::INCLUDE_IGNORED,
1805 cx,
1806 );
1807 }),
1808 ),
1809 )
1810 }),
1811 )
1812 .child(
1813 h_stack()
1814 .flex_1()
1815 .h_full()
1816 .px_2()
1817 .py_1()
1818 .border_1()
1819 .border_color(cx.theme().colors().border)
1820 .rounded_lg()
1821 .child(self.render_text_input(&search.excluded_files_editor, cx)),
1822 ),
1823 )
1824 })
1825 }
1826}
1827// impl Entity for ProjectSearchBar {
1828// type Event = ();
1829// }
1830
1831// impl View for ProjectSearchBar {
1832// fn ui_name() -> &'static str {
1833// "ProjectSearchBar"
1834// }
1835
1836// fn update_keymap_context(
1837// &self,
1838// keymap: &mut gpui::keymap_matcher::KeymapContext,
1839// cx: &AppContext,
1840// ) {
1841// Self::reset_to_default_keymap_context(keymap);
1842// let in_replace = self
1843// .active_project_search
1844// .as_ref()
1845// .map(|search| {
1846// search
1847// .read(cx)
1848// .replacement_editor
1849// .read_with(cx, |_, cx| cx.is_self_focused())
1850// })
1851// .flatten()
1852// .unwrap_or(false);
1853// if in_replace {
1854// keymap.add_identifier("in_replace");
1855// }
1856// }
1857
1858// fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
1859// if let Some(_search) = self.active_project_search.as_ref() {
1860// let search = _search.read(cx);
1861// let theme = theme::current(cx).clone();
1862// let query_container_style = if search.panels_with_errors.contains(&InputPanel::Query) {
1863// theme.search.invalid_editor
1864// } else {
1865// theme.search.editor.input.container
1866// };
1867
1868// let search = _search.read(cx);
1869// let filter_button = render_option_button_icon(
1870// search.filters_enabled,
1871// "icons/filter.svg",
1872// 0,
1873// "Toggle filters",
1874// Box::new(ToggleFilters),
1875// move |_, this, cx| {
1876// this.toggle_filters(cx);
1877// },
1878// cx,
1879// );
1880
1881// let search = _search.read(cx);
1882// let is_semantic_available = SemanticIndex::enabled(cx);
1883// let is_semantic_disabled = search.semantic_state.is_none();
1884// let icon_style = theme.search.editor_icon.clone();
1885// let is_active = search.active_match_index.is_some();
1886
1887// let render_option_button_icon = |path, option, cx: &mut ViewContext<Self>| {
1888// crate::search_bar::render_option_button_icon(
1889// self.is_option_enabled(option, cx),
1890// path,
1891// option.bits as usize,
1892// format!("Toggle {}", option.label()),
1893// option.to_toggle_action(),
1894// move |_, this, cx| {
1895// this.toggle_search_option(option, cx);
1896// },
1897// cx,
1898// )
1899// };
1900// let case_sensitive = is_semantic_disabled.then(|| {
1901// render_option_button_icon(
1902// "icons/case_insensitive.svg",
1903// SearchOptions::CASE_SENSITIVE,
1904// cx,
1905// )
1906// });
1907
1908// let whole_word = is_semantic_disabled.then(|| {
1909// render_option_button_icon("icons/word_search.svg", SearchOptions::WHOLE_WORD, cx)
1910// });
1911
1912// let include_ignored = is_semantic_disabled.then(|| {
1913// render_option_button_icon(
1914// "icons/file_icons/git.svg",
1915// SearchOptions::INCLUDE_IGNORED,
1916// cx,
1917// )
1918// });
1919
1920// let search_button_for_mode = |mode, side, cx: &mut ViewContext<ProjectSearchBar>| {
1921// let is_active = if let Some(search) = self.active_project_search.as_ref() {
1922// let search = search.read(cx);
1923// search.current_mode == mode
1924// } else {
1925// false
1926// };
1927// render_search_mode_button(
1928// mode,
1929// side,
1930// is_active,
1931// move |_, this, cx| {
1932// this.activate_search_mode(mode, cx);
1933// },
1934// cx,
1935// )
1936// };
1937
1938// let search = _search.read(cx);
1939
1940// let include_container_style =
1941// if search.panels_with_errors.contains(&InputPanel::Include) {
1942// theme.search.invalid_include_exclude_editor
1943// } else {
1944// theme.search.include_exclude_editor.input.container
1945// };
1946
1947// let exclude_container_style =
1948// if search.panels_with_errors.contains(&InputPanel::Exclude) {
1949// theme.search.invalid_include_exclude_editor
1950// } else {
1951// theme.search.include_exclude_editor.input.container
1952// };
1953
1954// let matches = search.active_match_index.map(|match_ix| {
1955// Label::new(
1956// format!(
1957// "{}/{}",
1958// match_ix + 1,
1959// search.model.read(cx).match_ranges.len()
1960// ),
1961// theme.search.match_index.text.clone(),
1962// )
1963// .contained()
1964// .with_style(theme.search.match_index.container)
1965// .aligned()
1966// });
1967// let should_show_replace_input = search.replace_enabled;
1968// let replacement = should_show_replace_input.then(|| {
1969// Flex::row()
1970// .with_child(
1971// Svg::for_style(theme.search.replace_icon.clone().icon)
1972// .contained()
1973// .with_style(theme.search.replace_icon.clone().container),
1974// )
1975// .with_child(ChildView::new(&search.replacement_editor, cx).flex(1., true))
1976// .align_children_center()
1977// .flex(1., true)
1978// .contained()
1979// .with_style(query_container_style)
1980// .constrained()
1981// .with_min_width(theme.search.editor.min_width)
1982// .with_max_width(theme.search.editor.max_width)
1983// .with_height(theme.search.search_bar_row_height)
1984// .flex(1., false)
1985// });
1986// let replace_all = should_show_replace_input.then(|| {
1987// super::replace_action(
1988// ReplaceAll,
1989// "Replace all",
1990// "icons/replace_all.svg",
1991// theme.tooltip.clone(),
1992// theme.search.action_button.clone(),
1993// )
1994// });
1995// let replace_next = should_show_replace_input.then(|| {
1996// super::replace_action(
1997// ReplaceNext,
1998// "Replace next",
1999// "icons/replace_next.svg",
2000// theme.tooltip.clone(),
2001// theme.search.action_button.clone(),
2002// )
2003// });
2004// let query_column = Flex::column()
2005// .with_spacing(theme.search.search_row_spacing)
2006// .with_child(
2007// Flex::row()
2008// .with_child(
2009// Svg::for_style(icon_style.icon)
2010// .contained()
2011// .with_style(icon_style.container),
2012// )
2013// .with_child(ChildView::new(&search.query_editor, cx).flex(1., true))
2014// .with_child(
2015// Flex::row()
2016// .with_child(filter_button)
2017// .with_children(case_sensitive)
2018// .with_children(whole_word)
2019// .flex(1., false)
2020// .constrained()
2021// .contained(),
2022// )
2023// .align_children_center()
2024// .contained()
2025// .with_style(query_container_style)
2026// .constrained()
2027// .with_min_width(theme.search.editor.min_width)
2028// .with_max_width(theme.search.editor.max_width)
2029// .with_height(theme.search.search_bar_row_height)
2030// .flex(1., false),
2031// )
2032// .with_children(search.filters_enabled.then(|| {
2033// Flex::row()
2034// .with_child(
2035// Flex::row()
2036// .with_child(
2037// ChildView::new(&search.included_files_editor, cx)
2038// .contained()
2039// .constrained()
2040// .with_height(theme.search.search_bar_row_height)
2041// .flex(1., true),
2042// )
2043// .with_children(include_ignored)
2044// .contained()
2045// .with_style(include_container_style)
2046// .constrained()
2047// .with_height(theme.search.search_bar_row_height)
2048// .flex(1., true),
2049// )
2050// .with_child(
2051// ChildView::new(&search.excluded_files_editor, cx)
2052// .contained()
2053// .with_style(exclude_container_style)
2054// .constrained()
2055// .with_height(theme.search.search_bar_row_height)
2056// .flex(1., true),
2057// )
2058// .constrained()
2059// .with_min_width(theme.search.editor.min_width)
2060// .with_max_width(theme.search.editor.max_width)
2061// .flex(1., false)
2062// }))
2063// .flex(1., false);
2064// let switches_column = Flex::row()
2065// .align_children_center()
2066// .with_child(super::toggle_replace_button(
2067// search.replace_enabled,
2068// theme.tooltip.clone(),
2069// theme.search.option_button_component.clone(),
2070// ))
2071// .constrained()
2072// .with_height(theme.search.search_bar_row_height)
2073// .contained()
2074// .with_style(theme.search.option_button_group);
2075// let mode_column =
2076// Flex::row()
2077// .with_child(search_button_for_mode(
2078// SearchMode::Text,
2079// Some(Side::Left),
2080// cx,
2081// ))
2082// .with_child(search_button_for_mode(
2083// SearchMode::Regex,
2084// if is_semantic_available {
2085// None
2086// } else {
2087// Some(Side::Right)
2088// },
2089// cx,
2090// ))
2091// .with_children(is_semantic_available.then(|| {
2092// search_button_for_mode(SearchMode::Semantic, Some(Side::Right), cx)
2093// }))
2094// .contained()
2095// .with_style(theme.search.modes_container);
2096
2097// let nav_button_for_direction = |label, direction, cx: &mut ViewContext<Self>| {
2098// render_nav_button(
2099// label,
2100// direction,
2101// is_active,
2102// move |_, this, cx| {
2103// if let Some(search) = this.active_project_search.as_ref() {
2104// search.update(cx, |search, cx| search.select_match(direction, cx));
2105// }
2106// },
2107// cx,
2108// )
2109// };
2110
2111// let nav_column = Flex::row()
2112// .with_children(replace_next)
2113// .with_children(replace_all)
2114// .with_child(Flex::row().with_children(matches))
2115// .with_child(nav_button_for_direction("<", Direction::Prev, cx))
2116// .with_child(nav_button_for_direction(">", Direction::Next, cx))
2117// .constrained()
2118// .with_height(theme.search.search_bar_row_height)
2119// .flex_float();
2120
2121// Flex::row()
2122// .with_child(query_column)
2123// .with_child(mode_column)
2124// .with_child(switches_column)
2125// .with_children(replacement)
2126// .with_child(nav_column)
2127// .contained()
2128// .with_style(theme.search.container)
2129// .into_any_named("project search")
2130// } else {
2131// Empty::new().into_any()
2132// }
2133// }
2134// }
2135
2136impl EventEmitter<ToolbarItemEvent> for ProjectSearchBar {}
2137
2138impl ToolbarItemView for ProjectSearchBar {
2139 fn set_active_pane_item(
2140 &mut self,
2141 active_pane_item: Option<&dyn ItemHandle>,
2142 cx: &mut ViewContext<Self>,
2143 ) -> ToolbarItemLocation {
2144 cx.notify();
2145 self.subscription = None;
2146 self.active_project_search = None;
2147 if let Some(search) = active_pane_item.and_then(|i| i.downcast::<ProjectSearchView>()) {
2148 search.update(cx, |search, cx| {
2149 if search.current_mode == SearchMode::Semantic {
2150 search.index_project(cx);
2151 }
2152 });
2153
2154 self.subscription = Some(cx.observe(&search, |_, _, cx| cx.notify()));
2155 self.active_project_search = Some(search);
2156 ToolbarItemLocation::PrimaryLeft {}
2157 } else {
2158 ToolbarItemLocation::Hidden
2159 }
2160 }
2161
2162 fn row_count(&self, cx: &WindowContext<'_>) -> usize {
2163 if let Some(search) = self.active_project_search.as_ref() {
2164 if search.read(cx).filters_enabled {
2165 return 2;
2166 }
2167 }
2168 1
2169 }
2170}
2171
2172#[cfg(test)]
2173pub mod tests {
2174 use super::*;
2175 use editor::DisplayPoint;
2176 use gpui::{Action, TestAppContext};
2177 use project::FakeFs;
2178 use semantic_index::semantic_index_settings::SemanticIndexSettings;
2179 use serde_json::json;
2180 use settings::{Settings, SettingsStore};
2181
2182 #[gpui::test]
2183 async fn test_project_search(cx: &mut TestAppContext) {
2184 init_test(cx);
2185
2186 let fs = FakeFs::new(cx.background_executor.clone());
2187 fs.insert_tree(
2188 "/dir",
2189 json!({
2190 "one.rs": "const ONE: usize = 1;",
2191 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2192 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2193 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2194 }),
2195 )
2196 .await;
2197 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2198 let search = cx.build_model(|cx| ProjectSearch::new(project, cx));
2199 let search_view = cx.add_window(|cx| ProjectSearchView::new(search.clone(), cx, None));
2200
2201 search_view
2202 .update(cx, |search_view, cx| {
2203 search_view
2204 .query_editor
2205 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
2206 search_view.search(cx);
2207 })
2208 .unwrap();
2209 cx.background_executor.run_until_parked();
2210 search_view.update(cx, |search_view, cx| {
2211 assert_eq!(
2212 search_view
2213 .results_editor
2214 .update(cx, |editor, cx| editor.display_text(cx)),
2215 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;"
2216 );
2217 let match_background_color = cx.theme().colors().search_match_background;
2218 assert_eq!(
2219 search_view
2220 .results_editor
2221 .update(cx, |editor, cx| editor.all_text_background_highlights(cx)),
2222 &[
2223 (
2224 DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35),
2225 match_background_color
2226 ),
2227 (
2228 DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40),
2229 match_background_color
2230 ),
2231 (
2232 DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9),
2233 match_background_color
2234 )
2235 ]
2236 );
2237 assert_eq!(search_view.active_match_index, Some(0));
2238 assert_eq!(
2239 search_view
2240 .results_editor
2241 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2242 [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
2243 );
2244
2245 search_view.select_match(Direction::Next, cx);
2246 }).unwrap();
2247
2248 search_view
2249 .update(cx, |search_view, cx| {
2250 assert_eq!(search_view.active_match_index, Some(1));
2251 assert_eq!(
2252 search_view
2253 .results_editor
2254 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2255 [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
2256 );
2257 search_view.select_match(Direction::Next, cx);
2258 })
2259 .unwrap();
2260
2261 search_view
2262 .update(cx, |search_view, cx| {
2263 assert_eq!(search_view.active_match_index, Some(2));
2264 assert_eq!(
2265 search_view
2266 .results_editor
2267 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2268 [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
2269 );
2270 search_view.select_match(Direction::Next, cx);
2271 })
2272 .unwrap();
2273
2274 search_view
2275 .update(cx, |search_view, cx| {
2276 assert_eq!(search_view.active_match_index, Some(0));
2277 assert_eq!(
2278 search_view
2279 .results_editor
2280 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2281 [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
2282 );
2283 search_view.select_match(Direction::Prev, cx);
2284 })
2285 .unwrap();
2286
2287 search_view
2288 .update(cx, |search_view, cx| {
2289 assert_eq!(search_view.active_match_index, Some(2));
2290 assert_eq!(
2291 search_view
2292 .results_editor
2293 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2294 [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
2295 );
2296 search_view.select_match(Direction::Prev, cx);
2297 })
2298 .unwrap();
2299
2300 search_view
2301 .update(cx, |search_view, cx| {
2302 assert_eq!(search_view.active_match_index, Some(1));
2303 assert_eq!(
2304 search_view
2305 .results_editor
2306 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2307 [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
2308 );
2309 })
2310 .unwrap();
2311 }
2312
2313 #[gpui::test]
2314 async fn test_project_search_focus(cx: &mut TestAppContext) {
2315 init_test(cx);
2316
2317 let fs = FakeFs::new(cx.background_executor.clone());
2318 fs.insert_tree(
2319 "/dir",
2320 json!({
2321 "one.rs": "const ONE: usize = 1;",
2322 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2323 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2324 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2325 }),
2326 )
2327 .await;
2328 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2329 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2330 let workspace = window.clone();
2331 let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
2332
2333 let active_item = cx.read(|cx| {
2334 workspace
2335 .read(cx)
2336 .unwrap()
2337 .active_pane()
2338 .read(cx)
2339 .active_item()
2340 .and_then(|item| item.downcast::<ProjectSearchView>())
2341 });
2342 assert!(
2343 active_item.is_none(),
2344 "Expected no search panel to be active"
2345 );
2346
2347 window
2348 .update(cx, move |workspace, cx| {
2349 assert_eq!(workspace.panes().len(), 1);
2350 workspace.panes()[0].update(cx, move |pane, cx| {
2351 pane.toolbar()
2352 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2353 });
2354
2355 ProjectSearchView::deploy(workspace, &workspace::NewSearch, cx)
2356 })
2357 .unwrap();
2358
2359 let Some(search_view) = cx.read(|cx| {
2360 workspace
2361 .read(cx)
2362 .unwrap()
2363 .active_pane()
2364 .read(cx)
2365 .active_item()
2366 .and_then(|item| item.downcast::<ProjectSearchView>())
2367 }) else {
2368 panic!("Search view expected to appear after new search event trigger")
2369 };
2370
2371 cx.spawn(|mut cx| async move {
2372 window
2373 .update(&mut cx, |_, cx| {
2374 cx.dispatch_action(ToggleFocus.boxed_clone())
2375 })
2376 .unwrap();
2377 })
2378 .detach();
2379 cx.background_executor.run_until_parked();
2380
2381 window.update(cx, |_, cx| {
2382 search_view.update(cx, |search_view, cx| {
2383 assert!(
2384 search_view.query_editor.focus_handle(cx).is_focused(cx),
2385 "Empty search view should be focused after the toggle focus event: no results panel to focus on",
2386 );
2387 });
2388 }).unwrap();
2389
2390 window
2391 .update(cx, |_, cx| {
2392 search_view.update(cx, |search_view, cx| {
2393 let query_editor = &search_view.query_editor;
2394 assert!(
2395 query_editor.focus_handle(cx).is_focused(cx),
2396 "Search view should be focused after the new search view is activated",
2397 );
2398 let query_text = query_editor.read(cx).text(cx);
2399 assert!(
2400 query_text.is_empty(),
2401 "New search query should be empty but got '{query_text}'",
2402 );
2403 let results_text = search_view
2404 .results_editor
2405 .update(cx, |editor, cx| editor.display_text(cx));
2406 assert!(
2407 results_text.is_empty(),
2408 "Empty search view should have no results but got '{results_text}'"
2409 );
2410 });
2411 })
2412 .unwrap();
2413
2414 window
2415 .update(cx, |_, cx| {
2416 search_view.update(cx, |search_view, cx| {
2417 search_view.query_editor.update(cx, |query_editor, cx| {
2418 query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", cx)
2419 });
2420 search_view.search(cx);
2421 });
2422 })
2423 .unwrap();
2424
2425 cx.background_executor.run_until_parked();
2426 window
2427 .update(cx, |_, cx| {
2428 search_view.update(cx, |search_view, cx| {
2429 let results_text = search_view
2430 .results_editor
2431 .update(cx, |editor, cx| editor.display_text(cx));
2432 assert!(
2433 results_text.is_empty(),
2434 "Search view for mismatching query should have no results but got '{results_text}'"
2435 );
2436 assert!(
2437 search_view.query_editor.focus_handle(cx).is_focused(cx),
2438 "Search view should be focused after mismatching query had been used in search",
2439 );
2440 });
2441 })
2442 .unwrap();
2443 cx.spawn(|mut cx| async move {
2444 window.update(&mut cx, |_, cx| {
2445 cx.dispatch_action(ToggleFocus.boxed_clone())
2446 })
2447 })
2448 .detach();
2449 cx.background_executor.run_until_parked();
2450 window.update(cx, |_, cx| {
2451 search_view.update(cx, |search_view, cx| {
2452 assert!(
2453 search_view.query_editor.focus_handle(cx).is_focused(cx),
2454 "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
2455 );
2456 });
2457 }).unwrap();
2458
2459 window
2460 .update(cx, |_, cx| {
2461 search_view.update(cx, |search_view, cx| {
2462 search_view
2463 .query_editor
2464 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
2465 search_view.search(cx);
2466 })
2467 })
2468 .unwrap();
2469 cx.background_executor.run_until_parked();
2470 window.update(cx, |_, cx|
2471 search_view.update(cx, |search_view, cx| {
2472 assert_eq!(
2473 search_view
2474 .results_editor
2475 .update(cx, |editor, cx| editor.display_text(cx)),
2476 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2477 "Search view results should match the query"
2478 );
2479 assert!(
2480 search_view.results_editor.focus_handle(cx).is_focused(cx),
2481 "Search view with mismatching query should be focused after search results are available",
2482 );
2483 })).unwrap();
2484 cx.spawn(|mut cx| async move {
2485 window
2486 .update(&mut cx, |_, cx| {
2487 cx.dispatch_action(ToggleFocus.boxed_clone())
2488 })
2489 .unwrap();
2490 })
2491 .detach();
2492 cx.background_executor.run_until_parked();
2493 window.update(cx, |_, cx| {
2494 search_view.update(cx, |search_view, cx| {
2495 assert!(
2496 search_view.results_editor.focus_handle(cx).is_focused(cx),
2497 "Search view with matching query should still have its results editor focused after the toggle focus event",
2498 );
2499 });
2500 }).unwrap();
2501
2502 workspace
2503 .update(cx, |workspace, cx| {
2504 ProjectSearchView::deploy(workspace, &workspace::NewSearch, cx)
2505 })
2506 .unwrap();
2507 cx.background_executor.run_until_parked();
2508 let Some(search_view_2) = cx.read(|cx| {
2509 workspace
2510 .read(cx)
2511 .unwrap()
2512 .active_pane()
2513 .read(cx)
2514 .active_item()
2515 .and_then(|item| item.downcast::<ProjectSearchView>())
2516 }) else {
2517 panic!("Search view expected to appear after new search event trigger")
2518 };
2519 assert!(
2520 search_view_2 != search_view,
2521 "New search view should be open after `workspace::NewSearch` event"
2522 );
2523
2524 window.update(cx, |_, cx| {
2525 search_view.update(cx, |search_view, cx| {
2526 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO", "First search view should not have an updated query");
2527 assert_eq!(
2528 search_view
2529 .results_editor
2530 .update(cx, |editor, cx| editor.display_text(cx)),
2531 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2532 "Results of the first search view should not update too"
2533 );
2534 assert!(
2535 !search_view.query_editor.focus_handle(cx).is_focused(cx),
2536 "Focus should be moved away from the first search view"
2537 );
2538 });
2539 }).unwrap();
2540
2541 window.update(cx, |_, cx| {
2542 search_view_2.update(cx, |search_view_2, cx| {
2543 assert_eq!(
2544 search_view_2.query_editor.read(cx).text(cx),
2545 "two",
2546 "New search view should get the query from the text cursor was at during the event spawn (first search view's first result)"
2547 );
2548 assert_eq!(
2549 search_view_2
2550 .results_editor
2551 .update(cx, |editor, cx| editor.display_text(cx)),
2552 "",
2553 "No search results should be in the 2nd view yet, as we did not spawn a search for it"
2554 );
2555 assert!(
2556 search_view_2.query_editor.focus_handle(cx).is_focused(cx),
2557 "Focus should be moved into query editor fo the new window"
2558 );
2559 });
2560 }).unwrap();
2561
2562 window
2563 .update(cx, |_, cx| {
2564 search_view_2.update(cx, |search_view_2, cx| {
2565 search_view_2
2566 .query_editor
2567 .update(cx, |query_editor, cx| query_editor.set_text("FOUR", cx));
2568 search_view_2.search(cx);
2569 });
2570 })
2571 .unwrap();
2572
2573 cx.background_executor.run_until_parked();
2574 window.update(cx, |_, cx| {
2575 search_view_2.update(cx, |search_view_2, cx| {
2576 assert_eq!(
2577 search_view_2
2578 .results_editor
2579 .update(cx, |editor, cx| editor.display_text(cx)),
2580 "\n\nconst FOUR: usize = one::ONE + three::THREE;",
2581 "New search view with the updated query should have new search results"
2582 );
2583 assert!(
2584 search_view_2.results_editor.focus_handle(cx).is_focused(cx),
2585 "Search view with mismatching query should be focused after search results are available",
2586 );
2587 });
2588 }).unwrap();
2589
2590 cx.spawn(|mut cx| async move {
2591 window
2592 .update(&mut cx, |_, cx| {
2593 cx.dispatch_action(ToggleFocus.boxed_clone())
2594 })
2595 .unwrap();
2596 })
2597 .detach();
2598 cx.background_executor.run_until_parked();
2599 window.update(cx, |_, cx| {
2600 search_view_2.update(cx, |search_view_2, cx| {
2601 assert!(
2602 search_view_2.results_editor.focus_handle(cx).is_focused(cx),
2603 "Search view with matching query should switch focus to the results editor after the toggle focus event",
2604 );
2605 });}).unwrap();
2606 }
2607
2608 #[gpui::test]
2609 async fn test_new_project_search_in_directory(cx: &mut TestAppContext) {
2610 init_test(cx);
2611
2612 let fs = FakeFs::new(cx.background_executor.clone());
2613 fs.insert_tree(
2614 "/dir",
2615 json!({
2616 "a": {
2617 "one.rs": "const ONE: usize = 1;",
2618 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2619 },
2620 "b": {
2621 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2622 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2623 },
2624 }),
2625 )
2626 .await;
2627 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2628 let worktree_id = project.read_with(cx, |project, cx| {
2629 project.worktrees().next().unwrap().read(cx).id()
2630 });
2631 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2632 let workspace = window.root(cx).unwrap();
2633 let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
2634
2635 let active_item = cx.read(|cx| {
2636 workspace
2637 .read(cx)
2638 .active_pane()
2639 .read(cx)
2640 .active_item()
2641 .and_then(|item| item.downcast::<ProjectSearchView>())
2642 });
2643 assert!(
2644 active_item.is_none(),
2645 "Expected no search panel to be active"
2646 );
2647
2648 window
2649 .update(cx, move |workspace, cx| {
2650 assert_eq!(workspace.panes().len(), 1);
2651 workspace.panes()[0].update(cx, move |pane, cx| {
2652 pane.toolbar()
2653 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2654 });
2655 })
2656 .unwrap();
2657
2658 let one_file_entry = cx.update(|cx| {
2659 workspace
2660 .read(cx)
2661 .project()
2662 .read(cx)
2663 .entry_for_path(&(worktree_id, "a/one.rs").into(), cx)
2664 .expect("no entry for /a/one.rs file")
2665 });
2666 assert!(one_file_entry.is_file());
2667 window
2668 .update(cx, |workspace, cx| {
2669 ProjectSearchView::new_search_in_directory(workspace, &one_file_entry, cx)
2670 })
2671 .unwrap();
2672 let active_search_entry = cx.read(|cx| {
2673 workspace
2674 .read(cx)
2675 .active_pane()
2676 .read(cx)
2677 .active_item()
2678 .and_then(|item| item.downcast::<ProjectSearchView>())
2679 });
2680 assert!(
2681 active_search_entry.is_none(),
2682 "Expected no search panel to be active for file entry"
2683 );
2684
2685 let a_dir_entry = cx.update(|cx| {
2686 workspace
2687 .read(cx)
2688 .project()
2689 .read(cx)
2690 .entry_for_path(&(worktree_id, "a").into(), cx)
2691 .expect("no entry for /a/ directory")
2692 });
2693 assert!(a_dir_entry.is_dir());
2694 window
2695 .update(cx, |workspace, cx| {
2696 ProjectSearchView::new_search_in_directory(workspace, &a_dir_entry, cx)
2697 })
2698 .unwrap();
2699
2700 let Some(search_view) = cx.read(|cx| {
2701 workspace
2702 .read(cx)
2703 .active_pane()
2704 .read(cx)
2705 .active_item()
2706 .and_then(|item| item.downcast::<ProjectSearchView>())
2707 }) else {
2708 panic!("Search view expected to appear after new search in directory event trigger")
2709 };
2710 cx.background_executor.run_until_parked();
2711 window
2712 .update(cx, |_, cx| {
2713 search_view.update(cx, |search_view, cx| {
2714 assert!(
2715 search_view.query_editor.focus_handle(cx).is_focused(cx),
2716 "On new search in directory, focus should be moved into query editor"
2717 );
2718 search_view.excluded_files_editor.update(cx, |editor, cx| {
2719 assert!(
2720 editor.display_text(cx).is_empty(),
2721 "New search in directory should not have any excluded files"
2722 );
2723 });
2724 search_view.included_files_editor.update(cx, |editor, cx| {
2725 assert_eq!(
2726 editor.display_text(cx),
2727 a_dir_entry.path.to_str().unwrap(),
2728 "New search in directory should have included dir entry path"
2729 );
2730 });
2731 });
2732 })
2733 .unwrap();
2734 window
2735 .update(cx, |_, cx| {
2736 search_view.update(cx, |search_view, cx| {
2737 search_view
2738 .query_editor
2739 .update(cx, |query_editor, cx| query_editor.set_text("const", cx));
2740 search_view.search(cx);
2741 });
2742 })
2743 .unwrap();
2744 cx.background_executor.run_until_parked();
2745 window
2746 .update(cx, |_, cx| {
2747 search_view.update(cx, |search_view, cx| {
2748 assert_eq!(
2749 search_view
2750 .results_editor
2751 .update(cx, |editor, cx| editor.display_text(cx)),
2752 "\n\nconst ONE: usize = 1;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2753 "New search in directory should have a filter that matches a certain directory"
2754 );
2755 })
2756 })
2757 .unwrap();
2758 }
2759
2760 #[gpui::test]
2761 async fn test_search_query_history(cx: &mut TestAppContext) {
2762 init_test(cx);
2763
2764 let fs = FakeFs::new(cx.background_executor.clone());
2765 fs.insert_tree(
2766 "/dir",
2767 json!({
2768 "one.rs": "const ONE: usize = 1;",
2769 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2770 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2771 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2772 }),
2773 )
2774 .await;
2775 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2776 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2777 let workspace = window.root(cx).unwrap();
2778 let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
2779
2780 window
2781 .update(cx, {
2782 let search_bar = search_bar.clone();
2783 move |workspace, cx| {
2784 assert_eq!(workspace.panes().len(), 1);
2785 workspace.panes()[0].update(cx, move |pane, cx| {
2786 pane.toolbar()
2787 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2788 });
2789
2790 ProjectSearchView::deploy(workspace, &workspace::NewSearch, cx)
2791 }
2792 })
2793 .unwrap();
2794
2795 let search_view = cx.read(|cx| {
2796 workspace
2797 .read(cx)
2798 .active_pane()
2799 .read(cx)
2800 .active_item()
2801 .and_then(|item| item.downcast::<ProjectSearchView>())
2802 .expect("Search view expected to appear after new search event trigger")
2803 });
2804
2805 // Add 3 search items into the history + another unsubmitted one.
2806 window
2807 .update(cx, |_, cx| {
2808 search_view.update(cx, |search_view, cx| {
2809 search_view.search_options = SearchOptions::CASE_SENSITIVE;
2810 search_view
2811 .query_editor
2812 .update(cx, |query_editor, cx| query_editor.set_text("ONE", cx));
2813 search_view.search(cx);
2814 });
2815 })
2816 .unwrap();
2817
2818 cx.background_executor.run_until_parked();
2819 window
2820 .update(cx, |_, cx| {
2821 search_view.update(cx, |search_view, cx| {
2822 search_view
2823 .query_editor
2824 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
2825 search_view.search(cx);
2826 });
2827 })
2828 .unwrap();
2829 cx.background_executor.run_until_parked();
2830 window
2831 .update(cx, |_, cx| {
2832 search_view.update(cx, |search_view, cx| {
2833 search_view
2834 .query_editor
2835 .update(cx, |query_editor, cx| query_editor.set_text("THREE", cx));
2836 search_view.search(cx);
2837 })
2838 })
2839 .unwrap();
2840 cx.background_executor.run_until_parked();
2841 window
2842 .update(cx, |_, cx| {
2843 search_view.update(cx, |search_view, cx| {
2844 search_view.query_editor.update(cx, |query_editor, cx| {
2845 query_editor.set_text("JUST_TEXT_INPUT", cx)
2846 });
2847 })
2848 })
2849 .unwrap();
2850 cx.background_executor.run_until_parked();
2851
2852 // Ensure that the latest input with search settings is active.
2853 window
2854 .update(cx, |_, cx| {
2855 search_view.update(cx, |search_view, cx| {
2856 assert_eq!(
2857 search_view.query_editor.read(cx).text(cx),
2858 "JUST_TEXT_INPUT"
2859 );
2860 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2861 });
2862 })
2863 .unwrap();
2864
2865 // Next history query after the latest should set the query to the empty string.
2866 window
2867 .update(cx, |_, cx| {
2868 search_bar.update(cx, |search_bar, cx| {
2869 search_bar.next_history_query(&NextHistoryQuery, cx);
2870 })
2871 })
2872 .unwrap();
2873 window
2874 .update(cx, |_, cx| {
2875 search_view.update(cx, |search_view, cx| {
2876 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
2877 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2878 });
2879 })
2880 .unwrap();
2881 window
2882 .update(cx, |_, cx| {
2883 search_bar.update(cx, |search_bar, cx| {
2884 search_bar.next_history_query(&NextHistoryQuery, cx);
2885 })
2886 })
2887 .unwrap();
2888 window
2889 .update(cx, |_, cx| {
2890 search_view.update(cx, |search_view, cx| {
2891 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
2892 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2893 });
2894 })
2895 .unwrap();
2896
2897 // First previous query for empty current query should set the query to the latest submitted one.
2898 window
2899 .update(cx, |_, cx| {
2900 search_bar.update(cx, |search_bar, cx| {
2901 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2902 });
2903 })
2904 .unwrap();
2905 window
2906 .update(cx, |_, cx| {
2907 search_view.update(cx, |search_view, cx| {
2908 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
2909 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2910 });
2911 })
2912 .unwrap();
2913
2914 // Further previous items should go over the history in reverse order.
2915 window
2916 .update(cx, |_, cx| {
2917 search_bar.update(cx, |search_bar, cx| {
2918 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2919 });
2920 })
2921 .unwrap();
2922 window
2923 .update(cx, |_, cx| {
2924 search_view.update(cx, |search_view, cx| {
2925 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
2926 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2927 });
2928 })
2929 .unwrap();
2930
2931 // Previous items should never go behind the first history item.
2932 window
2933 .update(cx, |_, cx| {
2934 search_bar.update(cx, |search_bar, cx| {
2935 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2936 });
2937 })
2938 .unwrap();
2939 window
2940 .update(cx, |_, cx| {
2941 search_view.update(cx, |search_view, cx| {
2942 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
2943 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2944 });
2945 })
2946 .unwrap();
2947 window
2948 .update(cx, |_, cx| {
2949 search_bar.update(cx, |search_bar, cx| {
2950 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2951 });
2952 })
2953 .unwrap();
2954 window
2955 .update(cx, |_, cx| {
2956 search_view.update(cx, |search_view, cx| {
2957 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
2958 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2959 });
2960 })
2961 .unwrap();
2962
2963 // Next items should go over the history in the original order.
2964 window
2965 .update(cx, |_, cx| {
2966 search_bar.update(cx, |search_bar, cx| {
2967 search_bar.next_history_query(&NextHistoryQuery, cx);
2968 });
2969 })
2970 .unwrap();
2971 window
2972 .update(cx, |_, cx| {
2973 search_view.update(cx, |search_view, cx| {
2974 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
2975 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2976 });
2977 })
2978 .unwrap();
2979
2980 window
2981 .update(cx, |_, cx| {
2982 search_view.update(cx, |search_view, cx| {
2983 search_view
2984 .query_editor
2985 .update(cx, |query_editor, cx| query_editor.set_text("TWO_NEW", cx));
2986 search_view.search(cx);
2987 });
2988 })
2989 .unwrap();
2990 cx.background_executor.run_until_parked();
2991 window
2992 .update(cx, |_, cx| {
2993 search_view.update(cx, |search_view, cx| {
2994 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
2995 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2996 });
2997 })
2998 .unwrap();
2999
3000 // New search input should add another entry to history and move the selection to the end of the history.
3001 window
3002 .update(cx, |_, cx| {
3003 search_bar.update(cx, |search_bar, cx| {
3004 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
3005 });
3006 })
3007 .unwrap();
3008 window
3009 .update(cx, |_, cx| {
3010 search_view.update(cx, |search_view, cx| {
3011 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
3012 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3013 });
3014 })
3015 .unwrap();
3016 window
3017 .update(cx, |_, cx| {
3018 search_bar.update(cx, |search_bar, cx| {
3019 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
3020 });
3021 })
3022 .unwrap();
3023 window
3024 .update(cx, |_, cx| {
3025 search_view.update(cx, |search_view, cx| {
3026 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
3027 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3028 });
3029 })
3030 .unwrap();
3031 window
3032 .update(cx, |_, cx| {
3033 search_bar.update(cx, |search_bar, cx| {
3034 search_bar.next_history_query(&NextHistoryQuery, cx);
3035 });
3036 })
3037 .unwrap();
3038 window
3039 .update(cx, |_, cx| {
3040 search_view.update(cx, |search_view, cx| {
3041 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
3042 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3043 });
3044 })
3045 .unwrap();
3046 window
3047 .update(cx, |_, cx| {
3048 search_bar.update(cx, |search_bar, cx| {
3049 search_bar.next_history_query(&NextHistoryQuery, cx);
3050 });
3051 })
3052 .unwrap();
3053 window
3054 .update(cx, |_, cx| {
3055 search_view.update(cx, |search_view, cx| {
3056 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
3057 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3058 });
3059 })
3060 .unwrap();
3061 window
3062 .update(cx, |_, cx| {
3063 search_bar.update(cx, |search_bar, cx| {
3064 search_bar.next_history_query(&NextHistoryQuery, cx);
3065 });
3066 })
3067 .unwrap();
3068 window
3069 .update(cx, |_, cx| {
3070 search_view.update(cx, |search_view, cx| {
3071 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
3072 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3073 });
3074 })
3075 .unwrap();
3076 }
3077
3078 pub fn init_test(cx: &mut TestAppContext) {
3079 cx.update(|cx| {
3080 let settings = SettingsStore::test(cx);
3081 cx.set_global(settings);
3082 cx.set_global(ActiveSearches::default());
3083 SemanticIndexSettings::register(cx);
3084
3085 theme::init(theme::LoadThemes::JustBase, cx);
3086
3087 language::init(cx);
3088 client::init_settings(cx);
3089 editor::init(cx);
3090 workspace::init_settings(cx);
3091 Project::init_settings(cx);
3092 super::init(cx);
3093 });
3094 }
3095}