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