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