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