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