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