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