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