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