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