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