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