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_size: rems(0.875).into(),
1342 font_weight: settings.buffer_font.weight,
1343 line_height: relative(1.3),
1344 ..Default::default()
1345 };
1346
1347 EditorElement::new(
1348 &editor,
1349 EditorStyle {
1350 background: cx.theme().colors().editor_background,
1351 local_player: cx.theme().players().local(),
1352 text: text_style,
1353 ..Default::default()
1354 },
1355 )
1356 }
1357}
1358
1359impl Render for ProjectSearchBar {
1360 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1361 let Some(search) = self.active_project_search.clone() else {
1362 return div();
1363 };
1364 let search = search.read(cx);
1365
1366 let query_column = h_flex()
1367 .flex_1()
1368 .h_8()
1369 .mr_2()
1370 .px_2()
1371 .py_1()
1372 .border_1()
1373 .border_color(search.border_color_for(InputPanel::Query, cx))
1374 .rounded_lg()
1375 .min_w(rems(MIN_INPUT_WIDTH_REMS))
1376 .max_w(rems(MAX_INPUT_WIDTH_REMS))
1377 .on_action(cx.listener(|this, action, cx| this.confirm(action, cx)))
1378 .on_action(cx.listener(|this, action, cx| this.previous_history_query(action, cx)))
1379 .on_action(cx.listener(|this, action, cx| this.next_history_query(action, cx)))
1380 .child(self.render_text_input(&search.query_editor, cx))
1381 .child(
1382 h_flex()
1383 .child(SearchOptions::CASE_SENSITIVE.as_button(
1384 self.is_option_enabled(SearchOptions::CASE_SENSITIVE, cx),
1385 cx.listener(|this, _, cx| {
1386 this.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
1387 }),
1388 ))
1389 .child(SearchOptions::WHOLE_WORD.as_button(
1390 self.is_option_enabled(SearchOptions::WHOLE_WORD, cx),
1391 cx.listener(|this, _, cx| {
1392 this.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
1393 }),
1394 ))
1395 .child(SearchOptions::REGEX.as_button(
1396 self.is_option_enabled(SearchOptions::REGEX, cx),
1397 cx.listener(|this, _, cx| {
1398 this.toggle_search_option(SearchOptions::REGEX, cx);
1399 }),
1400 )),
1401 );
1402
1403 let mode_column = v_flex().items_start().justify_start().child(
1404 h_flex()
1405 .child(
1406 IconButton::new("project-search-filter-button", IconName::Filter)
1407 .tooltip(|cx| Tooltip::for_action("Toggle filters", &ToggleFilters, cx))
1408 .on_click(cx.listener(|this, _, cx| {
1409 this.toggle_filters(cx);
1410 }))
1411 .selected(
1412 self.active_project_search
1413 .as_ref()
1414 .map(|search| search.read(cx).filters_enabled)
1415 .unwrap_or_default(),
1416 ),
1417 )
1418 .child(
1419 IconButton::new("project-search-toggle-replace", IconName::Replace)
1420 .on_click(cx.listener(|this, _, cx| {
1421 this.toggle_replace(&ToggleReplace, cx);
1422 }))
1423 .selected(
1424 self.active_project_search
1425 .as_ref()
1426 .map(|search| search.read(cx).replace_enabled)
1427 .unwrap_or_default(),
1428 )
1429 .tooltip(|cx| Tooltip::for_action("Toggle replace", &ToggleReplace, cx)),
1430 ),
1431 );
1432
1433 let limit_reached = search.model.read(cx).limit_reached;
1434 let match_text = search
1435 .active_match_index
1436 .and_then(|index| {
1437 let index = index + 1;
1438 let match_quantity = search.model.read(cx).match_ranges.len();
1439 if match_quantity > 0 {
1440 debug_assert!(match_quantity >= index);
1441 if limit_reached {
1442 Some(format!("{index}/{match_quantity}+").to_string())
1443 } else {
1444 Some(format!("{index}/{match_quantity}").to_string())
1445 }
1446 } else {
1447 None
1448 }
1449 })
1450 .unwrap_or_else(|| "0/0".to_string());
1451
1452 let matches_column = h_flex()
1453 .child(
1454 IconButton::new("project-search-prev-match", IconName::ChevronLeft)
1455 .disabled(search.active_match_index.is_none())
1456 .on_click(cx.listener(|this, _, cx| {
1457 if let Some(search) = this.active_project_search.as_ref() {
1458 search.update(cx, |this, cx| {
1459 this.select_match(Direction::Prev, cx);
1460 })
1461 }
1462 }))
1463 .tooltip(|cx| {
1464 Tooltip::for_action("Go to previous match", &SelectPrevMatch, cx)
1465 }),
1466 )
1467 .child(
1468 IconButton::new("project-search-next-match", IconName::ChevronRight)
1469 .disabled(search.active_match_index.is_none())
1470 .on_click(cx.listener(|this, _, cx| {
1471 if let Some(search) = this.active_project_search.as_ref() {
1472 search.update(cx, |this, cx| {
1473 this.select_match(Direction::Next, cx);
1474 })
1475 }
1476 }))
1477 .tooltip(|cx| Tooltip::for_action("Go to next match", &SelectNextMatch, cx)),
1478 )
1479 .child(
1480 h_flex()
1481 .id("matches")
1482 .min_w(rems_from_px(40.))
1483 .child(
1484 Label::new(match_text).color(if search.active_match_index.is_some() {
1485 Color::Default
1486 } else {
1487 Color::Disabled
1488 }),
1489 )
1490 .when(limit_reached, |el| {
1491 el.tooltip(|cx| {
1492 Tooltip::text("Search limits reached.\nTry narrowing your search.", cx)
1493 })
1494 }),
1495 );
1496
1497 let search_line = h_flex()
1498 .flex_1()
1499 .child(query_column)
1500 .child(mode_column)
1501 .child(matches_column);
1502
1503 let replace_line = search.replace_enabled.then(|| {
1504 let replace_column = h_flex()
1505 .flex_1()
1506 .min_w(rems(MIN_INPUT_WIDTH_REMS))
1507 .max_w(rems(MAX_INPUT_WIDTH_REMS))
1508 .h_8()
1509 .px_2()
1510 .py_1()
1511 .border_1()
1512 .border_color(cx.theme().colors().border)
1513 .rounded_lg()
1514 .child(self.render_text_input(&search.replacement_editor, cx));
1515 let replace_actions = h_flex().when(search.replace_enabled, |this| {
1516 this.child(
1517 IconButton::new("project-search-replace-next", IconName::ReplaceNext)
1518 .on_click(cx.listener(|this, _, cx| {
1519 if let Some(search) = this.active_project_search.as_ref() {
1520 search.update(cx, |this, cx| {
1521 this.replace_next(&ReplaceNext, cx);
1522 })
1523 }
1524 }))
1525 .tooltip(|cx| Tooltip::for_action("Replace next match", &ReplaceNext, cx)),
1526 )
1527 .child(
1528 IconButton::new("project-search-replace-all", IconName::ReplaceAll)
1529 .on_click(cx.listener(|this, _, cx| {
1530 if let Some(search) = this.active_project_search.as_ref() {
1531 search.update(cx, |this, cx| {
1532 this.replace_all(&ReplaceAll, cx);
1533 })
1534 }
1535 }))
1536 .tooltip(|cx| Tooltip::for_action("Replace all matches", &ReplaceAll, cx)),
1537 )
1538 });
1539 h_flex()
1540 .pr(rems(5.5))
1541 .gap_2()
1542 .child(replace_column)
1543 .child(replace_actions)
1544 });
1545
1546 let filter_line = search.filters_enabled.then(|| {
1547 h_flex()
1548 .w_full()
1549 .gap_2()
1550 .child(
1551 h_flex()
1552 .flex_1()
1553 // chosen so the total width of the search bar line
1554 // is about the same as the include/exclude line
1555 .min_w(rems(10.25))
1556 .max_w(rems(20.))
1557 .h_8()
1558 .px_2()
1559 .py_1()
1560 .border_1()
1561 .border_color(search.border_color_for(InputPanel::Include, cx))
1562 .rounded_lg()
1563 .child(self.render_text_input(&search.included_files_editor, cx)),
1564 )
1565 .child(
1566 h_flex()
1567 .flex_1()
1568 .min_w(rems(10.25))
1569 .max_w(rems(20.))
1570 .h_8()
1571 .px_2()
1572 .py_1()
1573 .border_1()
1574 .border_color(search.border_color_for(InputPanel::Exclude, cx))
1575 .rounded_lg()
1576 .child(self.render_text_input(&search.excluded_files_editor, cx)),
1577 )
1578 .child(
1579 SearchOptions::INCLUDE_IGNORED.as_button(
1580 search
1581 .search_options
1582 .contains(SearchOptions::INCLUDE_IGNORED),
1583 cx.listener(|this, _, cx| {
1584 this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, cx);
1585 }),
1586 ),
1587 )
1588 });
1589 let mut key_context = KeyContext::default();
1590 key_context.add("ProjectSearchBar");
1591 if search.replacement_editor.focus_handle(cx).is_focused(cx) {
1592 key_context.add("in_replace");
1593 }
1594
1595 v_flex()
1596 .key_context(key_context)
1597 .on_action(cx.listener(|this, _: &ToggleFocus, cx| this.move_focus_to_results(cx)))
1598 .on_action(cx.listener(|this, _: &ToggleFilters, cx| {
1599 this.toggle_filters(cx);
1600 }))
1601 .capture_action(cx.listener(|this, action, cx| {
1602 this.tab(action, cx);
1603 cx.stop_propagation();
1604 }))
1605 .capture_action(cx.listener(|this, action, cx| {
1606 this.tab_previous(action, cx);
1607 cx.stop_propagation();
1608 }))
1609 .on_action(cx.listener(|this, action, cx| this.confirm(action, cx)))
1610 .on_action(cx.listener(|this, action, cx| {
1611 this.toggle_replace(action, cx);
1612 }))
1613 .on_action(cx.listener(|this, _: &ToggleWholeWord, cx| {
1614 this.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
1615 }))
1616 .on_action(cx.listener(|this, _: &ToggleCaseSensitive, cx| {
1617 this.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
1618 }))
1619 .on_action(cx.listener(|this, action, cx| {
1620 if let Some(search) = this.active_project_search.as_ref() {
1621 search.update(cx, |this, cx| {
1622 this.replace_next(action, cx);
1623 })
1624 }
1625 }))
1626 .on_action(cx.listener(|this, action, cx| {
1627 if let Some(search) = this.active_project_search.as_ref() {
1628 search.update(cx, |this, cx| {
1629 this.replace_all(action, cx);
1630 })
1631 }
1632 }))
1633 .when(search.filters_enabled, |this| {
1634 this.on_action(cx.listener(|this, _: &ToggleIncludeIgnored, cx| {
1635 this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, cx);
1636 }))
1637 })
1638 .on_action(cx.listener(Self::select_next_match))
1639 .on_action(cx.listener(Self::select_prev_match))
1640 .gap_2()
1641 .w_full()
1642 .child(search_line)
1643 .children(replace_line)
1644 .children(filter_line)
1645 }
1646}
1647
1648impl EventEmitter<ToolbarItemEvent> for ProjectSearchBar {}
1649
1650impl ToolbarItemView for ProjectSearchBar {
1651 fn set_active_pane_item(
1652 &mut self,
1653 active_pane_item: Option<&dyn ItemHandle>,
1654 cx: &mut ViewContext<Self>,
1655 ) -> ToolbarItemLocation {
1656 cx.notify();
1657 self.subscription = None;
1658 self.active_project_search = None;
1659 if let Some(search) = active_pane_item.and_then(|i| i.downcast::<ProjectSearchView>()) {
1660 self.subscription = Some(cx.observe(&search, |_, _, cx| cx.notify()));
1661 self.active_project_search = Some(search);
1662 ToolbarItemLocation::PrimaryLeft {}
1663 } else {
1664 ToolbarItemLocation::Hidden
1665 }
1666 }
1667}
1668
1669fn register_workspace_action<A: Action>(
1670 workspace: &mut Workspace,
1671 callback: fn(&mut ProjectSearchBar, &A, &mut ViewContext<ProjectSearchBar>),
1672) {
1673 workspace.register_action(move |workspace, action: &A, cx| {
1674 if workspace.has_active_modal(cx) {
1675 cx.propagate();
1676 return;
1677 }
1678
1679 workspace.active_pane().update(cx, |pane, cx| {
1680 pane.toolbar().update(cx, move |workspace, cx| {
1681 if let Some(search_bar) = workspace.item_of_type::<ProjectSearchBar>() {
1682 search_bar.update(cx, move |search_bar, cx| {
1683 if search_bar.active_project_search.is_some() {
1684 callback(search_bar, action, cx);
1685 cx.notify();
1686 } else {
1687 cx.propagate();
1688 }
1689 });
1690 }
1691 });
1692 })
1693 });
1694}
1695
1696fn register_workspace_action_for_present_search<A: Action>(
1697 workspace: &mut Workspace,
1698 callback: fn(&mut Workspace, &A, &mut ViewContext<Workspace>),
1699) {
1700 workspace.register_action(move |workspace, action: &A, cx| {
1701 if workspace.has_active_modal(cx) {
1702 cx.propagate();
1703 return;
1704 }
1705
1706 let should_notify = workspace
1707 .active_pane()
1708 .read(cx)
1709 .toolbar()
1710 .read(cx)
1711 .item_of_type::<ProjectSearchBar>()
1712 .map(|search_bar| search_bar.read(cx).active_project_search.is_some())
1713 .unwrap_or(false);
1714 if should_notify {
1715 callback(workspace, action, cx);
1716 cx.notify();
1717 } else {
1718 cx.propagate();
1719 }
1720 });
1721}
1722
1723#[cfg(test)]
1724pub mod tests {
1725 use super::*;
1726 use editor::{display_map::DisplayRow, DisplayPoint};
1727 use gpui::{Action, TestAppContext, WindowHandle};
1728 use project::FakeFs;
1729 use serde_json::json;
1730 use settings::SettingsStore;
1731 use std::sync::Arc;
1732 use workspace::DeploySearch;
1733
1734 #[gpui::test]
1735 async fn test_project_search(cx: &mut TestAppContext) {
1736 init_test(cx);
1737
1738 let fs = FakeFs::new(cx.background_executor.clone());
1739 fs.insert_tree(
1740 "/dir",
1741 json!({
1742 "one.rs": "const ONE: usize = 1;",
1743 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
1744 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
1745 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
1746 }),
1747 )
1748 .await;
1749 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
1750 let search = cx.new_model(|cx| ProjectSearch::new(project, cx));
1751 let search_view = cx.add_window(|cx| ProjectSearchView::new(search.clone(), cx, None));
1752
1753 perform_search(search_view, "TWO", cx);
1754 search_view.update(cx, |search_view, cx| {
1755 assert_eq!(
1756 search_view
1757 .results_editor
1758 .update(cx, |editor, cx| editor.display_text(cx)),
1759 "\n\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n"
1760 );
1761 let match_background_color = cx.theme().colors().search_match_background;
1762 assert_eq!(
1763 search_view
1764 .results_editor
1765 .update(cx, |editor, cx| editor.all_text_background_highlights(cx)),
1766 &[
1767 (
1768 DisplayPoint::new(DisplayRow(3), 32)..DisplayPoint::new(DisplayRow(3), 35),
1769 match_background_color
1770 ),
1771 (
1772 DisplayPoint::new(DisplayRow(3), 37)..DisplayPoint::new(DisplayRow(3), 40),
1773 match_background_color
1774 ),
1775 (
1776 DisplayPoint::new(DisplayRow(8), 6)..DisplayPoint::new(DisplayRow(8), 9),
1777 match_background_color
1778 )
1779 ]
1780 );
1781 assert_eq!(search_view.active_match_index, Some(0));
1782 assert_eq!(
1783 search_view
1784 .results_editor
1785 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1786 [DisplayPoint::new(DisplayRow(3), 32)..DisplayPoint::new(DisplayRow(3), 35)]
1787 );
1788
1789 search_view.select_match(Direction::Next, cx);
1790 }).unwrap();
1791
1792 search_view
1793 .update(cx, |search_view, cx| {
1794 assert_eq!(search_view.active_match_index, Some(1));
1795 assert_eq!(
1796 search_view
1797 .results_editor
1798 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1799 [DisplayPoint::new(DisplayRow(3), 37)..DisplayPoint::new(DisplayRow(3), 40)]
1800 );
1801 search_view.select_match(Direction::Next, cx);
1802 })
1803 .unwrap();
1804
1805 search_view
1806 .update(cx, |search_view, cx| {
1807 assert_eq!(search_view.active_match_index, Some(2));
1808 assert_eq!(
1809 search_view
1810 .results_editor
1811 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1812 [DisplayPoint::new(DisplayRow(8), 6)..DisplayPoint::new(DisplayRow(8), 9)]
1813 );
1814 search_view.select_match(Direction::Next, cx);
1815 })
1816 .unwrap();
1817
1818 search_view
1819 .update(cx, |search_view, cx| {
1820 assert_eq!(search_view.active_match_index, Some(0));
1821 assert_eq!(
1822 search_view
1823 .results_editor
1824 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1825 [DisplayPoint::new(DisplayRow(3), 32)..DisplayPoint::new(DisplayRow(3), 35)]
1826 );
1827 search_view.select_match(Direction::Prev, cx);
1828 })
1829 .unwrap();
1830
1831 search_view
1832 .update(cx, |search_view, cx| {
1833 assert_eq!(search_view.active_match_index, Some(2));
1834 assert_eq!(
1835 search_view
1836 .results_editor
1837 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1838 [DisplayPoint::new(DisplayRow(8), 6)..DisplayPoint::new(DisplayRow(8), 9)]
1839 );
1840 search_view.select_match(Direction::Prev, cx);
1841 })
1842 .unwrap();
1843
1844 search_view
1845 .update(cx, |search_view, cx| {
1846 assert_eq!(search_view.active_match_index, Some(1));
1847 assert_eq!(
1848 search_view
1849 .results_editor
1850 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1851 [DisplayPoint::new(DisplayRow(3), 37)..DisplayPoint::new(DisplayRow(3), 40)]
1852 );
1853 })
1854 .unwrap();
1855 }
1856
1857 #[gpui::test]
1858 async fn test_deploy_project_search_focus(cx: &mut TestAppContext) {
1859 init_test(cx);
1860
1861 let fs = FakeFs::new(cx.background_executor.clone());
1862 fs.insert_tree(
1863 "/dir",
1864 json!({
1865 "one.rs": "const ONE: usize = 1;",
1866 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
1867 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
1868 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
1869 }),
1870 )
1871 .await;
1872 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
1873 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
1874 let workspace = window;
1875 let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
1876
1877 let active_item = cx.read(|cx| {
1878 workspace
1879 .read(cx)
1880 .unwrap()
1881 .active_pane()
1882 .read(cx)
1883 .active_item()
1884 .and_then(|item| item.downcast::<ProjectSearchView>())
1885 });
1886 assert!(
1887 active_item.is_none(),
1888 "Expected no search panel to be active"
1889 );
1890
1891 window
1892 .update(cx, move |workspace, cx| {
1893 assert_eq!(workspace.panes().len(), 1);
1894 workspace.panes()[0].update(cx, move |pane, cx| {
1895 pane.toolbar()
1896 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
1897 });
1898
1899 ProjectSearchView::deploy_search(workspace, &workspace::DeploySearch::find(), cx)
1900 })
1901 .unwrap();
1902
1903 let Some(search_view) = cx.read(|cx| {
1904 workspace
1905 .read(cx)
1906 .unwrap()
1907 .active_pane()
1908 .read(cx)
1909 .active_item()
1910 .and_then(|item| item.downcast::<ProjectSearchView>())
1911 }) else {
1912 panic!("Search view expected to appear after new search event trigger")
1913 };
1914
1915 cx.spawn(|mut cx| async move {
1916 window
1917 .update(&mut cx, |_, cx| {
1918 cx.dispatch_action(ToggleFocus.boxed_clone())
1919 })
1920 .unwrap();
1921 })
1922 .detach();
1923 cx.background_executor.run_until_parked();
1924 window
1925 .update(cx, |_, cx| {
1926 search_view.update(cx, |search_view, cx| {
1927 assert!(
1928 search_view.query_editor.focus_handle(cx).is_focused(cx),
1929 "Empty search view should be focused after the toggle focus event: no results panel to focus on",
1930 );
1931 });
1932 }).unwrap();
1933
1934 window
1935 .update(cx, |_, cx| {
1936 search_view.update(cx, |search_view, cx| {
1937 let query_editor = &search_view.query_editor;
1938 assert!(
1939 query_editor.focus_handle(cx).is_focused(cx),
1940 "Search view should be focused after the new search view is activated",
1941 );
1942 let query_text = query_editor.read(cx).text(cx);
1943 assert!(
1944 query_text.is_empty(),
1945 "New search query should be empty but got '{query_text}'",
1946 );
1947 let results_text = search_view
1948 .results_editor
1949 .update(cx, |editor, cx| editor.display_text(cx));
1950 assert!(
1951 results_text.is_empty(),
1952 "Empty search view should have no results but got '{results_text}'"
1953 );
1954 });
1955 })
1956 .unwrap();
1957
1958 window
1959 .update(cx, |_, cx| {
1960 search_view.update(cx, |search_view, cx| {
1961 search_view.query_editor.update(cx, |query_editor, cx| {
1962 query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", cx)
1963 });
1964 search_view.search(cx);
1965 });
1966 })
1967 .unwrap();
1968 cx.background_executor.run_until_parked();
1969 window
1970 .update(cx, |_, cx| {
1971 search_view.update(cx, |search_view, cx| {
1972 let results_text = search_view
1973 .results_editor
1974 .update(cx, |editor, cx| editor.display_text(cx));
1975 assert!(
1976 results_text.is_empty(),
1977 "Search view for mismatching query should have no results but got '{results_text}'"
1978 );
1979 assert!(
1980 search_view.query_editor.focus_handle(cx).is_focused(cx),
1981 "Search view should be focused after mismatching query had been used in search",
1982 );
1983 });
1984 }).unwrap();
1985
1986 cx.spawn(|mut cx| async move {
1987 window.update(&mut cx, |_, cx| {
1988 cx.dispatch_action(ToggleFocus.boxed_clone())
1989 })
1990 })
1991 .detach();
1992 cx.background_executor.run_until_parked();
1993 window.update(cx, |_, cx| {
1994 search_view.update(cx, |search_view, cx| {
1995 assert!(
1996 search_view.query_editor.focus_handle(cx).is_focused(cx),
1997 "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
1998 );
1999 });
2000 }).unwrap();
2001
2002 window
2003 .update(cx, |_, cx| {
2004 search_view.update(cx, |search_view, cx| {
2005 search_view
2006 .query_editor
2007 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
2008 search_view.search(cx);
2009 });
2010 })
2011 .unwrap();
2012 cx.background_executor.run_until_parked();
2013 window.update(cx, |_, cx| {
2014 search_view.update(cx, |search_view, cx| {
2015 assert_eq!(
2016 search_view
2017 .results_editor
2018 .update(cx, |editor, cx| editor.display_text(cx)),
2019 "\n\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n",
2020 "Search view results should match the query"
2021 );
2022 assert!(
2023 search_view.results_editor.focus_handle(cx).is_focused(cx),
2024 "Search view with mismatching query should be focused after search results are available",
2025 );
2026 });
2027 }).unwrap();
2028 cx.spawn(|mut cx| async move {
2029 window
2030 .update(&mut cx, |_, cx| {
2031 cx.dispatch_action(ToggleFocus.boxed_clone())
2032 })
2033 .unwrap();
2034 })
2035 .detach();
2036 cx.background_executor.run_until_parked();
2037 window.update(cx, |_, cx| {
2038 search_view.update(cx, |search_view, cx| {
2039 assert!(
2040 search_view.results_editor.focus_handle(cx).is_focused(cx),
2041 "Search view with matching query should still have its results editor focused after the toggle focus event",
2042 );
2043 });
2044 }).unwrap();
2045
2046 workspace
2047 .update(cx, |workspace, cx| {
2048 ProjectSearchView::deploy_search(workspace, &workspace::DeploySearch::find(), cx)
2049 })
2050 .unwrap();
2051 window.update(cx, |_, cx| {
2052 search_view.update(cx, |search_view, cx| {
2053 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");
2054 assert_eq!(
2055 search_view
2056 .results_editor
2057 .update(cx, |editor, cx| editor.display_text(cx)),
2058 "\n\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n",
2059 "Results should be unchanged after search view 2nd open in a row"
2060 );
2061 assert!(
2062 search_view.query_editor.focus_handle(cx).is_focused(cx),
2063 "Focus should be moved into query editor again after search view 2nd open in a row"
2064 );
2065 });
2066 }).unwrap();
2067
2068 cx.spawn(|mut cx| async move {
2069 window
2070 .update(&mut cx, |_, cx| {
2071 cx.dispatch_action(ToggleFocus.boxed_clone())
2072 })
2073 .unwrap();
2074 })
2075 .detach();
2076 cx.background_executor.run_until_parked();
2077 window.update(cx, |_, cx| {
2078 search_view.update(cx, |search_view, cx| {
2079 assert!(
2080 search_view.results_editor.focus_handle(cx).is_focused(cx),
2081 "Search view with matching query should switch focus to the results editor after the toggle focus event",
2082 );
2083 });
2084 }).unwrap();
2085 }
2086
2087 #[gpui::test]
2088 async fn test_new_project_search_focus(cx: &mut TestAppContext) {
2089 init_test(cx);
2090
2091 let fs = FakeFs::new(cx.background_executor.clone());
2092 fs.insert_tree(
2093 "/dir",
2094 json!({
2095 "one.rs": "const ONE: usize = 1;",
2096 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2097 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2098 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2099 }),
2100 )
2101 .await;
2102 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2103 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2104 let workspace = window;
2105 let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
2106
2107 let active_item = cx.read(|cx| {
2108 workspace
2109 .read(cx)
2110 .unwrap()
2111 .active_pane()
2112 .read(cx)
2113 .active_item()
2114 .and_then(|item| item.downcast::<ProjectSearchView>())
2115 });
2116 assert!(
2117 active_item.is_none(),
2118 "Expected no search panel to be active"
2119 );
2120
2121 window
2122 .update(cx, move |workspace, cx| {
2123 assert_eq!(workspace.panes().len(), 1);
2124 workspace.panes()[0].update(cx, move |pane, cx| {
2125 pane.toolbar()
2126 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2127 });
2128
2129 ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2130 })
2131 .unwrap();
2132
2133 let Some(search_view) = cx.read(|cx| {
2134 workspace
2135 .read(cx)
2136 .unwrap()
2137 .active_pane()
2138 .read(cx)
2139 .active_item()
2140 .and_then(|item| item.downcast::<ProjectSearchView>())
2141 }) else {
2142 panic!("Search view expected to appear after new search event trigger")
2143 };
2144
2145 cx.spawn(|mut cx| async move {
2146 window
2147 .update(&mut cx, |_, cx| {
2148 cx.dispatch_action(ToggleFocus.boxed_clone())
2149 })
2150 .unwrap();
2151 })
2152 .detach();
2153 cx.background_executor.run_until_parked();
2154
2155 window.update(cx, |_, cx| {
2156 search_view.update(cx, |search_view, cx| {
2157 assert!(
2158 search_view.query_editor.focus_handle(cx).is_focused(cx),
2159 "Empty search view should be focused after the toggle focus event: no results panel to focus on",
2160 );
2161 });
2162 }).unwrap();
2163
2164 window
2165 .update(cx, |_, cx| {
2166 search_view.update(cx, |search_view, cx| {
2167 let query_editor = &search_view.query_editor;
2168 assert!(
2169 query_editor.focus_handle(cx).is_focused(cx),
2170 "Search view should be focused after the new search view is activated",
2171 );
2172 let query_text = query_editor.read(cx).text(cx);
2173 assert!(
2174 query_text.is_empty(),
2175 "New search query should be empty but got '{query_text}'",
2176 );
2177 let results_text = search_view
2178 .results_editor
2179 .update(cx, |editor, cx| editor.display_text(cx));
2180 assert!(
2181 results_text.is_empty(),
2182 "Empty search view should have no results but got '{results_text}'"
2183 );
2184 });
2185 })
2186 .unwrap();
2187
2188 window
2189 .update(cx, |_, cx| {
2190 search_view.update(cx, |search_view, cx| {
2191 search_view.query_editor.update(cx, |query_editor, cx| {
2192 query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", cx)
2193 });
2194 search_view.search(cx);
2195 });
2196 })
2197 .unwrap();
2198
2199 cx.background_executor.run_until_parked();
2200 window
2201 .update(cx, |_, cx| {
2202 search_view.update(cx, |search_view, cx| {
2203 let results_text = search_view
2204 .results_editor
2205 .update(cx, |editor, cx| editor.display_text(cx));
2206 assert!(
2207 results_text.is_empty(),
2208 "Search view for mismatching query should have no results but got '{results_text}'"
2209 );
2210 assert!(
2211 search_view.query_editor.focus_handle(cx).is_focused(cx),
2212 "Search view should be focused after mismatching query had been used in search",
2213 );
2214 });
2215 })
2216 .unwrap();
2217 cx.spawn(|mut cx| async move {
2218 window.update(&mut cx, |_, cx| {
2219 cx.dispatch_action(ToggleFocus.boxed_clone())
2220 })
2221 })
2222 .detach();
2223 cx.background_executor.run_until_parked();
2224 window.update(cx, |_, cx| {
2225 search_view.update(cx, |search_view, cx| {
2226 assert!(
2227 search_view.query_editor.focus_handle(cx).is_focused(cx),
2228 "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
2229 );
2230 });
2231 }).unwrap();
2232
2233 window
2234 .update(cx, |_, cx| {
2235 search_view.update(cx, |search_view, cx| {
2236 search_view
2237 .query_editor
2238 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
2239 search_view.search(cx);
2240 })
2241 })
2242 .unwrap();
2243 cx.background_executor.run_until_parked();
2244 window.update(cx, |_, cx|
2245 search_view.update(cx, |search_view, cx| {
2246 assert_eq!(
2247 search_view
2248 .results_editor
2249 .update(cx, |editor, cx| editor.display_text(cx)),
2250 "\n\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n",
2251 "Search view results should match the query"
2252 );
2253 assert!(
2254 search_view.results_editor.focus_handle(cx).is_focused(cx),
2255 "Search view with mismatching query should be focused after search results are available",
2256 );
2257 })).unwrap();
2258 cx.spawn(|mut cx| async move {
2259 window
2260 .update(&mut cx, |_, cx| {
2261 cx.dispatch_action(ToggleFocus.boxed_clone())
2262 })
2263 .unwrap();
2264 })
2265 .detach();
2266 cx.background_executor.run_until_parked();
2267 window.update(cx, |_, cx| {
2268 search_view.update(cx, |search_view, cx| {
2269 assert!(
2270 search_view.results_editor.focus_handle(cx).is_focused(cx),
2271 "Search view with matching query should still have its results editor focused after the toggle focus event",
2272 );
2273 });
2274 }).unwrap();
2275
2276 workspace
2277 .update(cx, |workspace, cx| {
2278 ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2279 })
2280 .unwrap();
2281 cx.background_executor.run_until_parked();
2282 let Some(search_view_2) = cx.read(|cx| {
2283 workspace
2284 .read(cx)
2285 .unwrap()
2286 .active_pane()
2287 .read(cx)
2288 .active_item()
2289 .and_then(|item| item.downcast::<ProjectSearchView>())
2290 }) else {
2291 panic!("Search view expected to appear after new search event trigger")
2292 };
2293 assert!(
2294 search_view_2 != search_view,
2295 "New search view should be open after `workspace::NewSearch` event"
2296 );
2297
2298 window.update(cx, |_, cx| {
2299 search_view.update(cx, |search_view, cx| {
2300 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO", "First search view should not have an updated query");
2301 assert_eq!(
2302 search_view
2303 .results_editor
2304 .update(cx, |editor, cx| editor.display_text(cx)),
2305 "\n\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n",
2306 "Results of the first search view should not update too"
2307 );
2308 assert!(
2309 !search_view.query_editor.focus_handle(cx).is_focused(cx),
2310 "Focus should be moved away from the first search view"
2311 );
2312 });
2313 }).unwrap();
2314
2315 window.update(cx, |_, cx| {
2316 search_view_2.update(cx, |search_view_2, cx| {
2317 assert_eq!(
2318 search_view_2.query_editor.read(cx).text(cx),
2319 "two",
2320 "New search view should get the query from the text cursor was at during the event spawn (first search view's first result)"
2321 );
2322 assert_eq!(
2323 search_view_2
2324 .results_editor
2325 .update(cx, |editor, cx| editor.display_text(cx)),
2326 "",
2327 "No search results should be in the 2nd view yet, as we did not spawn a search for it"
2328 );
2329 assert!(
2330 search_view_2.query_editor.focus_handle(cx).is_focused(cx),
2331 "Focus should be moved into query editor of the new window"
2332 );
2333 });
2334 }).unwrap();
2335
2336 window
2337 .update(cx, |_, cx| {
2338 search_view_2.update(cx, |search_view_2, cx| {
2339 search_view_2
2340 .query_editor
2341 .update(cx, |query_editor, cx| query_editor.set_text("FOUR", cx));
2342 search_view_2.search(cx);
2343 });
2344 })
2345 .unwrap();
2346
2347 cx.background_executor.run_until_parked();
2348 window.update(cx, |_, cx| {
2349 search_view_2.update(cx, |search_view_2, cx| {
2350 assert_eq!(
2351 search_view_2
2352 .results_editor
2353 .update(cx, |editor, cx| editor.display_text(cx)),
2354 "\n\n\nconst FOUR: usize = one::ONE + three::THREE;\n",
2355 "New search view with the updated query should have new search results"
2356 );
2357 assert!(
2358 search_view_2.results_editor.focus_handle(cx).is_focused(cx),
2359 "Search view with mismatching query should be focused after search results are available",
2360 );
2361 });
2362 }).unwrap();
2363
2364 cx.spawn(|mut cx| async move {
2365 window
2366 .update(&mut cx, |_, cx| {
2367 cx.dispatch_action(ToggleFocus.boxed_clone())
2368 })
2369 .unwrap();
2370 })
2371 .detach();
2372 cx.background_executor.run_until_parked();
2373 window.update(cx, |_, cx| {
2374 search_view_2.update(cx, |search_view_2, cx| {
2375 assert!(
2376 search_view_2.results_editor.focus_handle(cx).is_focused(cx),
2377 "Search view with matching query should switch focus to the results editor after the toggle focus event",
2378 );
2379 });}).unwrap();
2380 }
2381
2382 #[gpui::test]
2383 async fn test_new_project_search_in_directory(cx: &mut TestAppContext) {
2384 init_test(cx);
2385
2386 let fs = FakeFs::new(cx.background_executor.clone());
2387 fs.insert_tree(
2388 "/dir",
2389 json!({
2390 "a": {
2391 "one.rs": "const ONE: usize = 1;",
2392 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2393 },
2394 "b": {
2395 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2396 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2397 },
2398 }),
2399 )
2400 .await;
2401 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2402 let worktree_id = project.read_with(cx, |project, cx| {
2403 project.worktrees(cx).next().unwrap().read(cx).id()
2404 });
2405 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2406 let workspace = window.root(cx).unwrap();
2407 let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
2408
2409 let active_item = cx.read(|cx| {
2410 workspace
2411 .read(cx)
2412 .active_pane()
2413 .read(cx)
2414 .active_item()
2415 .and_then(|item| item.downcast::<ProjectSearchView>())
2416 });
2417 assert!(
2418 active_item.is_none(),
2419 "Expected no search panel to be active"
2420 );
2421
2422 window
2423 .update(cx, move |workspace, cx| {
2424 assert_eq!(workspace.panes().len(), 1);
2425 workspace.panes()[0].update(cx, move |pane, cx| {
2426 pane.toolbar()
2427 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2428 });
2429 })
2430 .unwrap();
2431
2432 let a_dir_entry = cx.update(|cx| {
2433 workspace
2434 .read(cx)
2435 .project()
2436 .read(cx)
2437 .entry_for_path(&(worktree_id, "a").into(), cx)
2438 .expect("no entry for /a/ directory")
2439 });
2440 assert!(a_dir_entry.is_dir());
2441 window
2442 .update(cx, |workspace, cx| {
2443 ProjectSearchView::new_search_in_directory(workspace, &a_dir_entry.path, cx)
2444 })
2445 .unwrap();
2446
2447 let Some(search_view) = cx.read(|cx| {
2448 workspace
2449 .read(cx)
2450 .active_pane()
2451 .read(cx)
2452 .active_item()
2453 .and_then(|item| item.downcast::<ProjectSearchView>())
2454 }) else {
2455 panic!("Search view expected to appear after new search in directory event trigger")
2456 };
2457 cx.background_executor.run_until_parked();
2458 window
2459 .update(cx, |_, cx| {
2460 search_view.update(cx, |search_view, cx| {
2461 assert!(
2462 search_view.query_editor.focus_handle(cx).is_focused(cx),
2463 "On new search in directory, focus should be moved into query editor"
2464 );
2465 search_view.excluded_files_editor.update(cx, |editor, cx| {
2466 assert!(
2467 editor.display_text(cx).is_empty(),
2468 "New search in directory should not have any excluded files"
2469 );
2470 });
2471 search_view.included_files_editor.update(cx, |editor, cx| {
2472 assert_eq!(
2473 editor.display_text(cx),
2474 a_dir_entry.path.to_str().unwrap(),
2475 "New search in directory should have included dir entry path"
2476 );
2477 });
2478 });
2479 })
2480 .unwrap();
2481 window
2482 .update(cx, |_, cx| {
2483 search_view.update(cx, |search_view, cx| {
2484 search_view
2485 .query_editor
2486 .update(cx, |query_editor, cx| query_editor.set_text("const", cx));
2487 search_view.search(cx);
2488 });
2489 })
2490 .unwrap();
2491 cx.background_executor.run_until_parked();
2492 window
2493 .update(cx, |_, cx| {
2494 search_view.update(cx, |search_view, cx| {
2495 assert_eq!(
2496 search_view
2497 .results_editor
2498 .update(cx, |editor, cx| editor.display_text(cx)),
2499 "\n\n\nconst ONE: usize = 1;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n",
2500 "New search in directory should have a filter that matches a certain directory"
2501 );
2502 })
2503 })
2504 .unwrap();
2505 }
2506
2507 #[gpui::test]
2508 async fn test_search_query_history(cx: &mut TestAppContext) {
2509 init_test(cx);
2510
2511 let fs = FakeFs::new(cx.background_executor.clone());
2512 fs.insert_tree(
2513 "/dir",
2514 json!({
2515 "one.rs": "const ONE: usize = 1;",
2516 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2517 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2518 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2519 }),
2520 )
2521 .await;
2522 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2523 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2524 let workspace = window.root(cx).unwrap();
2525 let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
2526
2527 window
2528 .update(cx, {
2529 let search_bar = search_bar.clone();
2530 move |workspace, cx| {
2531 assert_eq!(workspace.panes().len(), 1);
2532 workspace.panes()[0].update(cx, move |pane, cx| {
2533 pane.toolbar()
2534 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2535 });
2536
2537 ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2538 }
2539 })
2540 .unwrap();
2541
2542 let search_view = cx.read(|cx| {
2543 workspace
2544 .read(cx)
2545 .active_pane()
2546 .read(cx)
2547 .active_item()
2548 .and_then(|item| item.downcast::<ProjectSearchView>())
2549 .expect("Search view expected to appear after new search event trigger")
2550 });
2551
2552 // Add 3 search items into the history + another unsubmitted one.
2553 window
2554 .update(cx, |_, cx| {
2555 search_view.update(cx, |search_view, cx| {
2556 search_view.search_options = SearchOptions::CASE_SENSITIVE;
2557 search_view
2558 .query_editor
2559 .update(cx, |query_editor, cx| query_editor.set_text("ONE", cx));
2560 search_view.search(cx);
2561 });
2562 })
2563 .unwrap();
2564
2565 cx.background_executor.run_until_parked();
2566 window
2567 .update(cx, |_, cx| {
2568 search_view.update(cx, |search_view, cx| {
2569 search_view
2570 .query_editor
2571 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
2572 search_view.search(cx);
2573 });
2574 })
2575 .unwrap();
2576 cx.background_executor.run_until_parked();
2577 window
2578 .update(cx, |_, cx| {
2579 search_view.update(cx, |search_view, cx| {
2580 search_view
2581 .query_editor
2582 .update(cx, |query_editor, cx| query_editor.set_text("THREE", cx));
2583 search_view.search(cx);
2584 })
2585 })
2586 .unwrap();
2587 cx.background_executor.run_until_parked();
2588 window
2589 .update(cx, |_, cx| {
2590 search_view.update(cx, |search_view, cx| {
2591 search_view.query_editor.update(cx, |query_editor, cx| {
2592 query_editor.set_text("JUST_TEXT_INPUT", cx)
2593 });
2594 })
2595 })
2596 .unwrap();
2597 cx.background_executor.run_until_parked();
2598
2599 // Ensure that the latest input with search settings is active.
2600 window
2601 .update(cx, |_, cx| {
2602 search_view.update(cx, |search_view, cx| {
2603 assert_eq!(
2604 search_view.query_editor.read(cx).text(cx),
2605 "JUST_TEXT_INPUT"
2606 );
2607 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2608 });
2609 })
2610 .unwrap();
2611
2612 // Next history query after the latest should set the query to the empty string.
2613 window
2614 .update(cx, |_, cx| {
2615 search_bar.update(cx, |search_bar, cx| {
2616 search_bar.next_history_query(&NextHistoryQuery, cx);
2617 })
2618 })
2619 .unwrap();
2620 window
2621 .update(cx, |_, cx| {
2622 search_view.update(cx, |search_view, cx| {
2623 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
2624 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2625 });
2626 })
2627 .unwrap();
2628 window
2629 .update(cx, |_, cx| {
2630 search_bar.update(cx, |search_bar, cx| {
2631 search_bar.next_history_query(&NextHistoryQuery, cx);
2632 })
2633 })
2634 .unwrap();
2635 window
2636 .update(cx, |_, cx| {
2637 search_view.update(cx, |search_view, cx| {
2638 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
2639 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2640 });
2641 })
2642 .unwrap();
2643
2644 // First previous query for empty current query should set the query to the latest submitted one.
2645 window
2646 .update(cx, |_, cx| {
2647 search_bar.update(cx, |search_bar, cx| {
2648 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2649 });
2650 })
2651 .unwrap();
2652 window
2653 .update(cx, |_, cx| {
2654 search_view.update(cx, |search_view, cx| {
2655 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
2656 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2657 });
2658 })
2659 .unwrap();
2660
2661 // Further previous items should go over the history in reverse order.
2662 window
2663 .update(cx, |_, cx| {
2664 search_bar.update(cx, |search_bar, cx| {
2665 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2666 });
2667 })
2668 .unwrap();
2669 window
2670 .update(cx, |_, cx| {
2671 search_view.update(cx, |search_view, cx| {
2672 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
2673 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2674 });
2675 })
2676 .unwrap();
2677
2678 // Previous items should never go behind the first history item.
2679 window
2680 .update(cx, |_, cx| {
2681 search_bar.update(cx, |search_bar, cx| {
2682 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2683 });
2684 })
2685 .unwrap();
2686 window
2687 .update(cx, |_, cx| {
2688 search_view.update(cx, |search_view, cx| {
2689 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
2690 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2691 });
2692 })
2693 .unwrap();
2694 window
2695 .update(cx, |_, cx| {
2696 search_bar.update(cx, |search_bar, cx| {
2697 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2698 });
2699 })
2700 .unwrap();
2701 window
2702 .update(cx, |_, cx| {
2703 search_view.update(cx, |search_view, cx| {
2704 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
2705 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2706 });
2707 })
2708 .unwrap();
2709
2710 // Next items should go over the history in the original order.
2711 window
2712 .update(cx, |_, cx| {
2713 search_bar.update(cx, |search_bar, cx| {
2714 search_bar.next_history_query(&NextHistoryQuery, cx);
2715 });
2716 })
2717 .unwrap();
2718 window
2719 .update(cx, |_, cx| {
2720 search_view.update(cx, |search_view, cx| {
2721 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
2722 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2723 });
2724 })
2725 .unwrap();
2726
2727 window
2728 .update(cx, |_, cx| {
2729 search_view.update(cx, |search_view, cx| {
2730 search_view
2731 .query_editor
2732 .update(cx, |query_editor, cx| query_editor.set_text("TWO_NEW", cx));
2733 search_view.search(cx);
2734 });
2735 })
2736 .unwrap();
2737 cx.background_executor.run_until_parked();
2738 window
2739 .update(cx, |_, cx| {
2740 search_view.update(cx, |search_view, cx| {
2741 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
2742 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2743 });
2744 })
2745 .unwrap();
2746
2747 // New search input should add another entry to history and move the selection to the end of the history.
2748 window
2749 .update(cx, |_, cx| {
2750 search_bar.update(cx, |search_bar, cx| {
2751 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2752 });
2753 })
2754 .unwrap();
2755 window
2756 .update(cx, |_, cx| {
2757 search_view.update(cx, |search_view, cx| {
2758 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
2759 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2760 });
2761 })
2762 .unwrap();
2763 window
2764 .update(cx, |_, cx| {
2765 search_bar.update(cx, |search_bar, cx| {
2766 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2767 });
2768 })
2769 .unwrap();
2770 window
2771 .update(cx, |_, cx| {
2772 search_view.update(cx, |search_view, cx| {
2773 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
2774 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2775 });
2776 })
2777 .unwrap();
2778 window
2779 .update(cx, |_, cx| {
2780 search_bar.update(cx, |search_bar, cx| {
2781 search_bar.next_history_query(&NextHistoryQuery, cx);
2782 });
2783 })
2784 .unwrap();
2785 window
2786 .update(cx, |_, cx| {
2787 search_view.update(cx, |search_view, cx| {
2788 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
2789 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2790 });
2791 })
2792 .unwrap();
2793 window
2794 .update(cx, |_, cx| {
2795 search_bar.update(cx, |search_bar, cx| {
2796 search_bar.next_history_query(&NextHistoryQuery, cx);
2797 });
2798 })
2799 .unwrap();
2800 window
2801 .update(cx, |_, cx| {
2802 search_view.update(cx, |search_view, cx| {
2803 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
2804 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2805 });
2806 })
2807 .unwrap();
2808 window
2809 .update(cx, |_, cx| {
2810 search_bar.update(cx, |search_bar, cx| {
2811 search_bar.next_history_query(&NextHistoryQuery, cx);
2812 });
2813 })
2814 .unwrap();
2815 window
2816 .update(cx, |_, cx| {
2817 search_view.update(cx, |search_view, cx| {
2818 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
2819 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2820 });
2821 })
2822 .unwrap();
2823 }
2824
2825 #[gpui::test]
2826 async fn test_search_query_history_with_multiple_views(cx: &mut TestAppContext) {
2827 init_test(cx);
2828
2829 let fs = FakeFs::new(cx.background_executor.clone());
2830 fs.insert_tree(
2831 "/dir",
2832 json!({
2833 "one.rs": "const ONE: usize = 1;",
2834 }),
2835 )
2836 .await;
2837 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2838 let worktree_id = project.update(cx, |this, cx| {
2839 this.worktrees(cx).next().unwrap().read(cx).id()
2840 });
2841
2842 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2843 let workspace = window.root(cx).unwrap();
2844
2845 let panes: Vec<_> = window
2846 .update(cx, |this, _| this.panes().to_owned())
2847 .unwrap();
2848
2849 let search_bar_1 = window.build_view(cx, |_| ProjectSearchBar::new());
2850 let search_bar_2 = window.build_view(cx, |_| ProjectSearchBar::new());
2851
2852 assert_eq!(panes.len(), 1);
2853 let first_pane = panes.get(0).cloned().unwrap();
2854 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 0);
2855 window
2856 .update(cx, |workspace, cx| {
2857 workspace.open_path(
2858 (worktree_id, "one.rs"),
2859 Some(first_pane.downgrade()),
2860 true,
2861 cx,
2862 )
2863 })
2864 .unwrap()
2865 .await
2866 .unwrap();
2867 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
2868
2869 // Add a project search item to the first pane
2870 window
2871 .update(cx, {
2872 let search_bar = search_bar_1.clone();
2873 let pane = first_pane.clone();
2874 move |workspace, cx| {
2875 pane.update(cx, move |pane, cx| {
2876 pane.toolbar()
2877 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2878 });
2879
2880 ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2881 }
2882 })
2883 .unwrap();
2884 let search_view_1 = cx.read(|cx| {
2885 workspace
2886 .read(cx)
2887 .active_item(cx)
2888 .and_then(|item| item.downcast::<ProjectSearchView>())
2889 .expect("Search view expected to appear after new search event trigger")
2890 });
2891
2892 let second_pane = window
2893 .update(cx, |workspace, cx| {
2894 workspace.split_and_clone(first_pane.clone(), workspace::SplitDirection::Right, cx)
2895 })
2896 .unwrap()
2897 .unwrap();
2898 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
2899
2900 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
2901 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 2);
2902
2903 // Add a project search item to the second pane
2904 window
2905 .update(cx, {
2906 let search_bar = search_bar_2.clone();
2907 let pane = second_pane.clone();
2908 move |workspace, cx| {
2909 assert_eq!(workspace.panes().len(), 2);
2910 pane.update(cx, move |pane, cx| {
2911 pane.toolbar()
2912 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2913 });
2914
2915 ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2916 }
2917 })
2918 .unwrap();
2919
2920 let search_view_2 = cx.read(|cx| {
2921 workspace
2922 .read(cx)
2923 .active_item(cx)
2924 .and_then(|item| item.downcast::<ProjectSearchView>())
2925 .expect("Search view expected to appear after new search event trigger")
2926 });
2927
2928 cx.run_until_parked();
2929 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 2);
2930 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 2);
2931
2932 let update_search_view =
2933 |search_view: &View<ProjectSearchView>, query: &str, cx: &mut TestAppContext| {
2934 window
2935 .update(cx, |_, cx| {
2936 search_view.update(cx, |search_view, cx| {
2937 search_view
2938 .query_editor
2939 .update(cx, |query_editor, cx| query_editor.set_text(query, cx));
2940 search_view.search(cx);
2941 });
2942 })
2943 .unwrap();
2944 };
2945
2946 let active_query =
2947 |search_view: &View<ProjectSearchView>, cx: &mut TestAppContext| -> String {
2948 window
2949 .update(cx, |_, cx| {
2950 search_view.update(cx, |search_view, cx| {
2951 search_view.query_editor.read(cx).text(cx).to_string()
2952 })
2953 })
2954 .unwrap()
2955 };
2956
2957 let select_prev_history_item =
2958 |search_bar: &View<ProjectSearchBar>, cx: &mut TestAppContext| {
2959 window
2960 .update(cx, |_, cx| {
2961 search_bar.update(cx, |search_bar, cx| {
2962 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2963 })
2964 })
2965 .unwrap();
2966 };
2967
2968 let select_next_history_item =
2969 |search_bar: &View<ProjectSearchBar>, cx: &mut TestAppContext| {
2970 window
2971 .update(cx, |_, cx| {
2972 search_bar.update(cx, |search_bar, cx| {
2973 search_bar.next_history_query(&NextHistoryQuery, cx);
2974 })
2975 })
2976 .unwrap();
2977 };
2978
2979 update_search_view(&search_view_1, "ONE", cx);
2980 cx.background_executor.run_until_parked();
2981
2982 update_search_view(&search_view_2, "TWO", cx);
2983 cx.background_executor.run_until_parked();
2984
2985 assert_eq!(active_query(&search_view_1, cx), "ONE");
2986 assert_eq!(active_query(&search_view_2, cx), "TWO");
2987
2988 // Selecting previous history item should select the query from search view 1.
2989 select_prev_history_item(&search_bar_2, cx);
2990 assert_eq!(active_query(&search_view_2, cx), "ONE");
2991
2992 // Selecting the previous history item should not change the query as it is already the first item.
2993 select_prev_history_item(&search_bar_2, cx);
2994 assert_eq!(active_query(&search_view_2, cx), "ONE");
2995
2996 // Changing the query in search view 2 should not affect the history of search view 1.
2997 assert_eq!(active_query(&search_view_1, cx), "ONE");
2998
2999 // Deploying a new search in search view 2
3000 update_search_view(&search_view_2, "THREE", cx);
3001 cx.background_executor.run_until_parked();
3002
3003 select_next_history_item(&search_bar_2, cx);
3004 assert_eq!(active_query(&search_view_2, cx), "");
3005
3006 select_prev_history_item(&search_bar_2, cx);
3007 assert_eq!(active_query(&search_view_2, cx), "THREE");
3008
3009 select_prev_history_item(&search_bar_2, cx);
3010 assert_eq!(active_query(&search_view_2, cx), "TWO");
3011
3012 select_prev_history_item(&search_bar_2, cx);
3013 assert_eq!(active_query(&search_view_2, cx), "ONE");
3014
3015 select_prev_history_item(&search_bar_2, cx);
3016 assert_eq!(active_query(&search_view_2, cx), "ONE");
3017
3018 // Search view 1 should now see the query from search view 2.
3019 assert_eq!(active_query(&search_view_1, cx), "ONE");
3020
3021 select_next_history_item(&search_bar_2, cx);
3022 assert_eq!(active_query(&search_view_2, cx), "TWO");
3023
3024 // Here is the new query from search view 2
3025 select_next_history_item(&search_bar_2, cx);
3026 assert_eq!(active_query(&search_view_2, cx), "THREE");
3027
3028 select_next_history_item(&search_bar_2, cx);
3029 assert_eq!(active_query(&search_view_2, cx), "");
3030
3031 select_next_history_item(&search_bar_1, cx);
3032 assert_eq!(active_query(&search_view_1, cx), "TWO");
3033
3034 select_next_history_item(&search_bar_1, cx);
3035 assert_eq!(active_query(&search_view_1, cx), "THREE");
3036
3037 select_next_history_item(&search_bar_1, cx);
3038 assert_eq!(active_query(&search_view_1, cx), "");
3039 }
3040
3041 #[gpui::test]
3042 async fn test_deploy_search_with_multiple_panes(cx: &mut TestAppContext) {
3043 init_test(cx);
3044
3045 // Setup 2 panes, both with a file open and one with a project search.
3046 let fs = FakeFs::new(cx.background_executor.clone());
3047 fs.insert_tree(
3048 "/dir",
3049 json!({
3050 "one.rs": "const ONE: usize = 1;",
3051 }),
3052 )
3053 .await;
3054 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
3055 let worktree_id = project.update(cx, |this, cx| {
3056 this.worktrees(cx).next().unwrap().read(cx).id()
3057 });
3058 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
3059 let panes: Vec<_> = window
3060 .update(cx, |this, _| this.panes().to_owned())
3061 .unwrap();
3062 assert_eq!(panes.len(), 1);
3063 let first_pane = panes.get(0).cloned().unwrap();
3064 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 0);
3065 window
3066 .update(cx, |workspace, cx| {
3067 workspace.open_path(
3068 (worktree_id, "one.rs"),
3069 Some(first_pane.downgrade()),
3070 true,
3071 cx,
3072 )
3073 })
3074 .unwrap()
3075 .await
3076 .unwrap();
3077 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3078 let second_pane = window
3079 .update(cx, |workspace, cx| {
3080 workspace.split_and_clone(first_pane.clone(), workspace::SplitDirection::Right, cx)
3081 })
3082 .unwrap()
3083 .unwrap();
3084 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
3085 assert!(window
3086 .update(cx, |_, cx| second_pane
3087 .focus_handle(cx)
3088 .contains_focused(cx))
3089 .unwrap());
3090 let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
3091 window
3092 .update(cx, {
3093 let search_bar = search_bar.clone();
3094 let pane = first_pane.clone();
3095 move |workspace, cx| {
3096 assert_eq!(workspace.panes().len(), 2);
3097 pane.update(cx, move |pane, cx| {
3098 pane.toolbar()
3099 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
3100 });
3101 }
3102 })
3103 .unwrap();
3104
3105 // Add a project search item to the second pane
3106 window
3107 .update(cx, {
3108 let search_bar = search_bar.clone();
3109 let pane = second_pane.clone();
3110 move |workspace, cx| {
3111 assert_eq!(workspace.panes().len(), 2);
3112 pane.update(cx, move |pane, cx| {
3113 pane.toolbar()
3114 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
3115 });
3116
3117 ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
3118 }
3119 })
3120 .unwrap();
3121
3122 cx.run_until_parked();
3123 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 2);
3124 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3125
3126 // Focus the first pane
3127 window
3128 .update(cx, |workspace, cx| {
3129 assert_eq!(workspace.active_pane(), &second_pane);
3130 second_pane.update(cx, |this, cx| {
3131 assert_eq!(this.active_item_index(), 1);
3132 this.activate_prev_item(false, cx);
3133 assert_eq!(this.active_item_index(), 0);
3134 });
3135 workspace.activate_pane_in_direction(workspace::SplitDirection::Left, cx);
3136 })
3137 .unwrap();
3138 window
3139 .update(cx, |workspace, cx| {
3140 assert_eq!(workspace.active_pane(), &first_pane);
3141 assert_eq!(first_pane.read(cx).items_len(), 1);
3142 assert_eq!(second_pane.read(cx).items_len(), 2);
3143 })
3144 .unwrap();
3145
3146 // Deploy a new search
3147 cx.dispatch_action(window.into(), DeploySearch::find());
3148
3149 // Both panes should now have a project search in them
3150 window
3151 .update(cx, |workspace, cx| {
3152 assert_eq!(workspace.active_pane(), &first_pane);
3153 first_pane.update(cx, |this, _| {
3154 assert_eq!(this.active_item_index(), 1);
3155 assert_eq!(this.items_len(), 2);
3156 });
3157 second_pane.update(cx, |this, cx| {
3158 assert!(!cx.focus_handle().contains_focused(cx));
3159 assert_eq!(this.items_len(), 2);
3160 });
3161 })
3162 .unwrap();
3163
3164 // Focus the second pane's non-search item
3165 window
3166 .update(cx, |_workspace, cx| {
3167 second_pane.update(cx, |pane, cx| pane.activate_next_item(true, cx));
3168 })
3169 .unwrap();
3170
3171 // Deploy a new search
3172 cx.dispatch_action(window.into(), DeploySearch::find());
3173
3174 // The project search view should now be focused in the second pane
3175 // And the number of items should be unchanged.
3176 window
3177 .update(cx, |_workspace, cx| {
3178 second_pane.update(cx, |pane, _cx| {
3179 assert!(pane
3180 .active_item()
3181 .unwrap()
3182 .downcast::<ProjectSearchView>()
3183 .is_some());
3184
3185 assert_eq!(pane.items_len(), 2);
3186 });
3187 })
3188 .unwrap();
3189 }
3190
3191 #[gpui::test]
3192 async fn test_scroll_search_results_to_top(cx: &mut TestAppContext) {
3193 init_test(cx);
3194
3195 // We need many lines in the search results to be able to scroll the window
3196 let fs = FakeFs::new(cx.background_executor.clone());
3197 fs.insert_tree(
3198 "/dir",
3199 json!({
3200 "1.txt": "\n\n\n\n\n A \n\n\n\n\n",
3201 "2.txt": "\n\n\n\n\n A \n\n\n\n\n",
3202 "3.rs": "\n\n\n\n\n A \n\n\n\n\n",
3203 "4.rs": "\n\n\n\n\n A \n\n\n\n\n",
3204 "5.rs": "\n\n\n\n\n A \n\n\n\n\n",
3205 "6.rs": "\n\n\n\n\n A \n\n\n\n\n",
3206 "7.rs": "\n\n\n\n\n A \n\n\n\n\n",
3207 "8.rs": "\n\n\n\n\n A \n\n\n\n\n",
3208 "9.rs": "\n\n\n\n\n A \n\n\n\n\n",
3209 "a.rs": "\n\n\n\n\n A \n\n\n\n\n",
3210 "b.rs": "\n\n\n\n\n B \n\n\n\n\n",
3211 "c.rs": "\n\n\n\n\n B \n\n\n\n\n",
3212 "d.rs": "\n\n\n\n\n B \n\n\n\n\n",
3213 "e.rs": "\n\n\n\n\n B \n\n\n\n\n",
3214 "f.rs": "\n\n\n\n\n B \n\n\n\n\n",
3215 "g.rs": "\n\n\n\n\n B \n\n\n\n\n",
3216 "h.rs": "\n\n\n\n\n B \n\n\n\n\n",
3217 "i.rs": "\n\n\n\n\n B \n\n\n\n\n",
3218 "j.rs": "\n\n\n\n\n B \n\n\n\n\n",
3219 "k.rs": "\n\n\n\n\n B \n\n\n\n\n",
3220 }),
3221 )
3222 .await;
3223 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
3224 let search = cx.new_model(|cx| ProjectSearch::new(project, cx));
3225 let search_view = cx.add_window(|cx| ProjectSearchView::new(search.clone(), cx, None));
3226
3227 // First search
3228 perform_search(search_view, "A", cx);
3229 search_view
3230 .update(cx, |search_view, cx| {
3231 search_view.results_editor.update(cx, |results_editor, cx| {
3232 // Results are correct and scrolled to the top
3233 assert_eq!(
3234 results_editor.display_text(cx).match_indices(" A ").count(),
3235 10
3236 );
3237 assert_eq!(results_editor.scroll_position(cx), Point::default());
3238
3239 // Scroll results all the way down
3240 results_editor.scroll(Point::new(0., f32::MAX), Some(Axis::Vertical), cx);
3241 });
3242 })
3243 .expect("unable to update search view");
3244
3245 // Second search
3246 perform_search(search_view, "B", cx);
3247 search_view
3248 .update(cx, |search_view, cx| {
3249 search_view.results_editor.update(cx, |results_editor, cx| {
3250 // Results are correct...
3251 assert_eq!(
3252 results_editor.display_text(cx).match_indices(" B ").count(),
3253 10
3254 );
3255 // ...and scrolled back to the top
3256 assert_eq!(results_editor.scroll_position(cx), Point::default());
3257 });
3258 })
3259 .expect("unable to update search view");
3260 }
3261
3262 fn init_test(cx: &mut TestAppContext) {
3263 cx.update(|cx| {
3264 let settings = SettingsStore::test(cx);
3265 cx.set_global(settings);
3266
3267 theme::init(theme::LoadThemes::JustBase, cx);
3268
3269 language::init(cx);
3270 client::init_settings(cx);
3271 editor::init(cx);
3272 workspace::init_settings(cx);
3273 Project::init_settings(cx);
3274 super::init(cx);
3275 });
3276 }
3277
3278 fn perform_search(
3279 search_view: WindowHandle<ProjectSearchView>,
3280 text: impl Into<Arc<str>>,
3281 cx: &mut TestAppContext,
3282 ) {
3283 search_view
3284 .update(cx, |search_view, cx| {
3285 search_view
3286 .query_editor
3287 .update(cx, |query_editor, cx| query_editor.set_text(text, cx));
3288 search_view.search(cx);
3289 })
3290 .unwrap();
3291 cx.background_executor.run_until_parked();
3292 }
3293}