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