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