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