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