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