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