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