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