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