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