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