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