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