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