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