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