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