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