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 settings::Settings;
29use smol::stream::StreamExt;
30use std::{
31 any::{Any, TypeId},
32 collections::HashSet,
33 mem,
34 ops::{Not, Range},
35 path::PathBuf,
36 time::{Duration, Instant},
37};
38use theme::ThemeSettings;
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::new(),
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 white_space: WhiteSpace::Normal,
1636 };
1637
1638 EditorElement::new(
1639 &editor,
1640 EditorStyle {
1641 background: cx.theme().colors().editor_background,
1642 local_player: cx.theme().players().local(),
1643 text: text_style,
1644 ..Default::default()
1645 },
1646 )
1647 }
1648}
1649
1650impl Render for ProjectSearchBar {
1651 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1652 let Some(search) = self.active_project_search.clone() else {
1653 return div();
1654 };
1655 let mut key_context = KeyContext::default();
1656 key_context.add("ProjectSearchBar");
1657 if let Some(placeholder_text) = self.new_placeholder_text(cx) {
1658 search.update(cx, |search, cx| {
1659 search.query_editor.update(cx, |this, cx| {
1660 this.set_placeholder_text(placeholder_text, cx)
1661 })
1662 });
1663 }
1664 let search = search.read(cx);
1665 let semantic_is_available = SemanticIndex::enabled(cx);
1666
1667 let query_column = v_flex().child(
1668 h_flex()
1669 .min_w(rems(512. / 16.))
1670 .px_2()
1671 .py_1()
1672 .gap_2()
1673 .bg(cx.theme().colors().editor_background)
1674 .border_1()
1675 .border_color(search.border_color_for(InputPanel::Query, cx))
1676 .rounded_lg()
1677 .on_action(cx.listener(|this, action, cx| this.confirm(action, cx)))
1678 .on_action(cx.listener(|this, action, cx| this.previous_history_query(action, cx)))
1679 .on_action(cx.listener(|this, action, cx| this.next_history_query(action, cx)))
1680 .child(Icon::new(IconName::MagnifyingGlass))
1681 .child(self.render_text_input(&search.query_editor, cx))
1682 .child(
1683 h_flex()
1684 .child(
1685 IconButton::new("project-search-filter-button", IconName::Filter)
1686 .tooltip(|cx| {
1687 Tooltip::for_action("Toggle filters", &ToggleFilters, cx)
1688 })
1689 .on_click(cx.listener(|this, _, cx| {
1690 this.toggle_filters(cx);
1691 }))
1692 .selected(
1693 self.active_project_search
1694 .as_ref()
1695 .map(|search| search.read(cx).filters_enabled)
1696 .unwrap_or_default(),
1697 ),
1698 )
1699 .when(search.current_mode != SearchMode::Semantic, |this| {
1700 this.child(
1701 IconButton::new(
1702 "project-search-case-sensitive",
1703 IconName::CaseSensitive,
1704 )
1705 .tooltip(|cx| {
1706 Tooltip::for_action(
1707 "Toggle case sensitive",
1708 &ToggleCaseSensitive,
1709 cx,
1710 )
1711 })
1712 .selected(self.is_option_enabled(SearchOptions::CASE_SENSITIVE, cx))
1713 .on_click(cx.listener(
1714 |this, _, cx| {
1715 this.toggle_search_option(
1716 SearchOptions::CASE_SENSITIVE,
1717 cx,
1718 );
1719 },
1720 )),
1721 )
1722 .child(
1723 IconButton::new("project-search-whole-word", IconName::WholeWord)
1724 .tooltip(|cx| {
1725 Tooltip::for_action(
1726 "Toggle whole word",
1727 &ToggleWholeWord,
1728 cx,
1729 )
1730 })
1731 .selected(self.is_option_enabled(SearchOptions::WHOLE_WORD, cx))
1732 .on_click(cx.listener(|this, _, cx| {
1733 this.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
1734 })),
1735 )
1736 }),
1737 ),
1738 );
1739
1740 let mode_column = v_flex().items_start().justify_start().child(
1741 h_flex()
1742 .gap_2()
1743 .child(
1744 h_flex()
1745 .child(
1746 ToggleButton::new("project-search-text-button", "Text")
1747 .style(ButtonStyle::Filled)
1748 .size(ButtonSize::Large)
1749 .selected(search.current_mode == SearchMode::Text)
1750 .on_click(cx.listener(|this, _, cx| {
1751 this.activate_search_mode(SearchMode::Text, cx)
1752 }))
1753 .tooltip(|cx| {
1754 Tooltip::for_action("Toggle text search", &ActivateTextMode, cx)
1755 })
1756 .first(),
1757 )
1758 .child(
1759 ToggleButton::new("project-search-regex-button", "Regex")
1760 .style(ButtonStyle::Filled)
1761 .size(ButtonSize::Large)
1762 .selected(search.current_mode == SearchMode::Regex)
1763 .on_click(cx.listener(|this, _, cx| {
1764 this.activate_search_mode(SearchMode::Regex, cx)
1765 }))
1766 .tooltip(|cx| {
1767 Tooltip::for_action(
1768 "Toggle regular expression search",
1769 &ActivateRegexMode,
1770 cx,
1771 )
1772 })
1773 .map(|this| {
1774 if semantic_is_available {
1775 this.middle()
1776 } else {
1777 this.last()
1778 }
1779 }),
1780 )
1781 .when(semantic_is_available, |this| {
1782 this.child(
1783 ToggleButton::new("project-search-semantic-button", "Semantic")
1784 .style(ButtonStyle::Filled)
1785 .size(ButtonSize::Large)
1786 .selected(search.current_mode == SearchMode::Semantic)
1787 .on_click(cx.listener(|this, _, cx| {
1788 this.activate_search_mode(SearchMode::Semantic, cx)
1789 }))
1790 .tooltip(|cx| {
1791 Tooltip::for_action(
1792 "Toggle semantic search",
1793 &ActivateSemanticMode,
1794 cx,
1795 )
1796 })
1797 .last(),
1798 )
1799 }),
1800 )
1801 .child(
1802 IconButton::new("project-search-toggle-replace", IconName::Replace)
1803 .on_click(cx.listener(|this, _, cx| {
1804 this.toggle_replace(&ToggleReplace, cx);
1805 }))
1806 .tooltip(|cx| Tooltip::for_action("Toggle replace", &ToggleReplace, cx)),
1807 ),
1808 );
1809 let replace_column = if search.replace_enabled {
1810 h_flex()
1811 .flex_1()
1812 .h_full()
1813 .gap_2()
1814 .px_2()
1815 .py_1()
1816 .border_1()
1817 .border_color(cx.theme().colors().border)
1818 .rounded_lg()
1819 .child(Icon::new(IconName::Replace).size(ui::IconSize::Small))
1820 .child(self.render_text_input(&search.replacement_editor, cx))
1821 } else {
1822 // Fill out the space if we don't have a replacement editor.
1823 h_flex().flex_1()
1824 };
1825 let actions_column = h_flex()
1826 .when(search.replace_enabled, |this| {
1827 this.child(
1828 IconButton::new("project-search-replace-next", IconName::ReplaceNext)
1829 .on_click(cx.listener(|this, _, cx| {
1830 if let Some(search) = this.active_project_search.as_ref() {
1831 search.update(cx, |this, cx| {
1832 this.replace_next(&ReplaceNext, cx);
1833 })
1834 }
1835 }))
1836 .tooltip(|cx| Tooltip::for_action("Replace next match", &ReplaceNext, cx)),
1837 )
1838 .child(
1839 IconButton::new("project-search-replace-all", IconName::ReplaceAll)
1840 .on_click(cx.listener(|this, _, cx| {
1841 if let Some(search) = this.active_project_search.as_ref() {
1842 search.update(cx, |this, cx| {
1843 this.replace_all(&ReplaceAll, cx);
1844 })
1845 }
1846 }))
1847 .tooltip(|cx| Tooltip::for_action("Replace all matches", &ReplaceAll, cx)),
1848 )
1849 })
1850 .when_some(search.active_match_index, |mut this, index| {
1851 let index = index + 1;
1852 let match_quantity = search.model.read(cx).match_ranges.len();
1853 if match_quantity > 0 {
1854 debug_assert!(match_quantity >= index);
1855 this = this.child(Label::new(format!("{index}/{match_quantity}")))
1856 }
1857 this
1858 })
1859 .child(
1860 IconButton::new("project-search-prev-match", IconName::ChevronLeft)
1861 .disabled(search.active_match_index.is_none())
1862 .on_click(cx.listener(|this, _, cx| {
1863 if let Some(search) = this.active_project_search.as_ref() {
1864 search.update(cx, |this, cx| {
1865 this.select_match(Direction::Prev, cx);
1866 })
1867 }
1868 }))
1869 .tooltip(|cx| {
1870 Tooltip::for_action("Go to previous match", &SelectPrevMatch, cx)
1871 }),
1872 )
1873 .child(
1874 IconButton::new("project-search-next-match", IconName::ChevronRight)
1875 .disabled(search.active_match_index.is_none())
1876 .on_click(cx.listener(|this, _, cx| {
1877 if let Some(search) = this.active_project_search.as_ref() {
1878 search.update(cx, |this, cx| {
1879 this.select_match(Direction::Next, cx);
1880 })
1881 }
1882 }))
1883 .tooltip(|cx| Tooltip::for_action("Go to next match", &SelectNextMatch, cx)),
1884 );
1885
1886 v_flex()
1887 .key_context(key_context)
1888 .flex_grow()
1889 .gap_2()
1890 .on_action(cx.listener(|this, _: &ToggleFocus, cx| this.move_focus_to_results(cx)))
1891 .on_action(cx.listener(|this, _: &ToggleFilters, cx| {
1892 this.toggle_filters(cx);
1893 }))
1894 .on_action(cx.listener(|this, _: &ActivateTextMode, cx| {
1895 this.activate_search_mode(SearchMode::Text, cx)
1896 }))
1897 .on_action(cx.listener(|this, _: &ActivateRegexMode, cx| {
1898 this.activate_search_mode(SearchMode::Regex, cx)
1899 }))
1900 .on_action(cx.listener(|this, _: &ActivateSemanticMode, cx| {
1901 this.activate_search_mode(SearchMode::Semantic, cx)
1902 }))
1903 .capture_action(cx.listener(|this, action, cx| {
1904 this.tab(action, cx);
1905 cx.stop_propagation();
1906 }))
1907 .capture_action(cx.listener(|this, action, cx| {
1908 this.tab_previous(action, cx);
1909 cx.stop_propagation();
1910 }))
1911 .on_action(cx.listener(|this, action, cx| this.confirm(action, cx)))
1912 .on_action(cx.listener(|this, action, cx| {
1913 this.cycle_mode(action, cx);
1914 }))
1915 .when(search.current_mode != SearchMode::Semantic, |this| {
1916 this.on_action(cx.listener(|this, action, cx| {
1917 this.toggle_replace(action, cx);
1918 }))
1919 .on_action(cx.listener(|this, _: &ToggleWholeWord, cx| {
1920 this.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
1921 }))
1922 .on_action(cx.listener(|this, _: &ToggleCaseSensitive, cx| {
1923 this.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
1924 }))
1925 .on_action(cx.listener(|this, action, cx| {
1926 if let Some(search) = this.active_project_search.as_ref() {
1927 search.update(cx, |this, cx| {
1928 this.replace_next(action, cx);
1929 })
1930 }
1931 }))
1932 .on_action(cx.listener(|this, action, cx| {
1933 if let Some(search) = this.active_project_search.as_ref() {
1934 search.update(cx, |this, cx| {
1935 this.replace_all(action, cx);
1936 })
1937 }
1938 }))
1939 .when(search.filters_enabled, |this| {
1940 this.on_action(cx.listener(|this, _: &ToggleIncludeIgnored, cx| {
1941 this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, cx);
1942 }))
1943 })
1944 })
1945 .on_action(cx.listener(Self::select_next_match))
1946 .on_action(cx.listener(Self::select_prev_match))
1947 .child(
1948 h_flex()
1949 .justify_between()
1950 .gap_2()
1951 .child(query_column)
1952 .child(mode_column)
1953 .child(replace_column)
1954 .child(actions_column),
1955 )
1956 .when(search.filters_enabled, |this| {
1957 this.child(
1958 h_flex()
1959 .flex_1()
1960 .gap_2()
1961 .justify_between()
1962 .child(
1963 h_flex()
1964 .flex_1()
1965 .h_full()
1966 .px_2()
1967 .py_1()
1968 .border_1()
1969 .border_color(search.border_color_for(InputPanel::Include, cx))
1970 .rounded_lg()
1971 .child(self.render_text_input(&search.included_files_editor, cx))
1972 .when(search.current_mode != SearchMode::Semantic, |this| {
1973 this.child(
1974 SearchOptions::INCLUDE_IGNORED.as_button(
1975 search
1976 .search_options
1977 .contains(SearchOptions::INCLUDE_IGNORED),
1978 cx.listener(|this, _, cx| {
1979 this.toggle_search_option(
1980 SearchOptions::INCLUDE_IGNORED,
1981 cx,
1982 );
1983 }),
1984 ),
1985 )
1986 }),
1987 )
1988 .child(
1989 h_flex()
1990 .flex_1()
1991 .h_full()
1992 .px_2()
1993 .py_1()
1994 .border_1()
1995 .border_color(search.border_color_for(InputPanel::Exclude, cx))
1996 .rounded_lg()
1997 .child(self.render_text_input(&search.excluded_files_editor, cx)),
1998 ),
1999 )
2000 })
2001 }
2002}
2003
2004impl EventEmitter<ToolbarItemEvent> for ProjectSearchBar {}
2005
2006impl ToolbarItemView for ProjectSearchBar {
2007 fn set_active_pane_item(
2008 &mut self,
2009 active_pane_item: Option<&dyn ItemHandle>,
2010 cx: &mut ViewContext<Self>,
2011 ) -> ToolbarItemLocation {
2012 cx.notify();
2013 self.subscription = None;
2014 self.active_project_search = None;
2015 if let Some(search) = active_pane_item.and_then(|i| i.downcast::<ProjectSearchView>()) {
2016 search.update(cx, |search, cx| {
2017 if search.current_mode == SearchMode::Semantic {
2018 search.index_project(cx);
2019 }
2020 });
2021
2022 self.subscription = Some(cx.observe(&search, |_, _, cx| cx.notify()));
2023 self.active_project_search = Some(search);
2024 ToolbarItemLocation::PrimaryLeft {}
2025 } else {
2026 ToolbarItemLocation::Hidden
2027 }
2028 }
2029
2030 fn row_count(&self, cx: &WindowContext<'_>) -> usize {
2031 if let Some(search) = self.active_project_search.as_ref() {
2032 if search.read(cx).filters_enabled {
2033 return 2;
2034 }
2035 }
2036 1
2037 }
2038}
2039
2040fn register_workspace_action<A: Action>(
2041 workspace: &mut Workspace,
2042 callback: fn(&mut ProjectSearchBar, &A, &mut ViewContext<ProjectSearchBar>),
2043) {
2044 workspace.register_action(move |workspace, action: &A, cx| {
2045 if workspace.has_active_modal(cx) {
2046 cx.propagate();
2047 return;
2048 }
2049
2050 workspace.active_pane().update(cx, |pane, cx| {
2051 pane.toolbar().update(cx, move |workspace, cx| {
2052 if let Some(search_bar) = workspace.item_of_type::<ProjectSearchBar>() {
2053 search_bar.update(cx, move |search_bar, cx| {
2054 if search_bar.active_project_search.is_some() {
2055 callback(search_bar, action, cx);
2056 cx.notify();
2057 } else {
2058 cx.propagate();
2059 }
2060 });
2061 }
2062 });
2063 })
2064 });
2065}
2066
2067fn register_workspace_action_for_present_search<A: Action>(
2068 workspace: &mut Workspace,
2069 callback: fn(&mut Workspace, &A, &mut ViewContext<Workspace>),
2070) {
2071 workspace.register_action(move |workspace, action: &A, cx| {
2072 if workspace.has_active_modal(cx) {
2073 cx.propagate();
2074 return;
2075 }
2076
2077 let should_notify = workspace
2078 .active_pane()
2079 .read(cx)
2080 .toolbar()
2081 .read(cx)
2082 .item_of_type::<ProjectSearchBar>()
2083 .map(|search_bar| search_bar.read(cx).active_project_search.is_some())
2084 .unwrap_or(false);
2085 if should_notify {
2086 callback(workspace, action, cx);
2087 cx.notify();
2088 } else {
2089 cx.propagate();
2090 }
2091 });
2092}
2093
2094#[cfg(test)]
2095pub mod tests {
2096 use super::*;
2097 use editor::DisplayPoint;
2098 use gpui::{Action, TestAppContext};
2099 use project::FakeFs;
2100 use semantic_index::semantic_index_settings::SemanticIndexSettings;
2101 use serde_json::json;
2102 use settings::{Settings, SettingsStore};
2103 use workspace::DeploySearch;
2104
2105 #[gpui::test]
2106 async fn test_project_search(cx: &mut TestAppContext) {
2107 init_test(cx);
2108
2109 let fs = FakeFs::new(cx.background_executor.clone());
2110 fs.insert_tree(
2111 "/dir",
2112 json!({
2113 "one.rs": "const ONE: usize = 1;",
2114 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2115 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2116 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2117 }),
2118 )
2119 .await;
2120 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2121 let search = cx.new_model(|cx| ProjectSearch::new(project, cx));
2122 let search_view = cx.add_window(|cx| ProjectSearchView::new(search.clone(), cx, None));
2123
2124 search_view
2125 .update(cx, |search_view, cx| {
2126 search_view
2127 .query_editor
2128 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
2129 search_view.search(cx);
2130 })
2131 .unwrap();
2132 cx.background_executor.run_until_parked();
2133 search_view.update(cx, |search_view, cx| {
2134 assert_eq!(
2135 search_view
2136 .results_editor
2137 .update(cx, |editor, cx| editor.display_text(cx)),
2138 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;"
2139 );
2140 let match_background_color = cx.theme().colors().search_match_background;
2141 assert_eq!(
2142 search_view
2143 .results_editor
2144 .update(cx, |editor, cx| editor.all_text_background_highlights(cx)),
2145 &[
2146 (
2147 DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35),
2148 match_background_color
2149 ),
2150 (
2151 DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40),
2152 match_background_color
2153 ),
2154 (
2155 DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9),
2156 match_background_color
2157 )
2158 ]
2159 );
2160 assert_eq!(search_view.active_match_index, Some(0));
2161 assert_eq!(
2162 search_view
2163 .results_editor
2164 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2165 [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
2166 );
2167
2168 search_view.select_match(Direction::Next, cx);
2169 }).unwrap();
2170
2171 search_view
2172 .update(cx, |search_view, cx| {
2173 assert_eq!(search_view.active_match_index, Some(1));
2174 assert_eq!(
2175 search_view
2176 .results_editor
2177 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2178 [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
2179 );
2180 search_view.select_match(Direction::Next, cx);
2181 })
2182 .unwrap();
2183
2184 search_view
2185 .update(cx, |search_view, cx| {
2186 assert_eq!(search_view.active_match_index, Some(2));
2187 assert_eq!(
2188 search_view
2189 .results_editor
2190 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2191 [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
2192 );
2193 search_view.select_match(Direction::Next, cx);
2194 })
2195 .unwrap();
2196
2197 search_view
2198 .update(cx, |search_view, cx| {
2199 assert_eq!(search_view.active_match_index, Some(0));
2200 assert_eq!(
2201 search_view
2202 .results_editor
2203 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2204 [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
2205 );
2206 search_view.select_match(Direction::Prev, cx);
2207 })
2208 .unwrap();
2209
2210 search_view
2211 .update(cx, |search_view, cx| {
2212 assert_eq!(search_view.active_match_index, Some(2));
2213 assert_eq!(
2214 search_view
2215 .results_editor
2216 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2217 [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
2218 );
2219 search_view.select_match(Direction::Prev, cx);
2220 })
2221 .unwrap();
2222
2223 search_view
2224 .update(cx, |search_view, cx| {
2225 assert_eq!(search_view.active_match_index, Some(1));
2226 assert_eq!(
2227 search_view
2228 .results_editor
2229 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2230 [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
2231 );
2232 })
2233 .unwrap();
2234 }
2235
2236 #[gpui::test]
2237 async fn test_deploy_project_search_focus(cx: &mut TestAppContext) {
2238 init_test(cx);
2239
2240 let fs = FakeFs::new(cx.background_executor.clone());
2241 fs.insert_tree(
2242 "/dir",
2243 json!({
2244 "one.rs": "const ONE: usize = 1;",
2245 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2246 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2247 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2248 }),
2249 )
2250 .await;
2251 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2252 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2253 let workspace = window.clone();
2254 let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
2255
2256 let active_item = cx.read(|cx| {
2257 workspace
2258 .read(cx)
2259 .unwrap()
2260 .active_pane()
2261 .read(cx)
2262 .active_item()
2263 .and_then(|item| item.downcast::<ProjectSearchView>())
2264 });
2265 assert!(
2266 active_item.is_none(),
2267 "Expected no search panel to be active"
2268 );
2269
2270 window
2271 .update(cx, move |workspace, cx| {
2272 assert_eq!(workspace.panes().len(), 1);
2273 workspace.panes()[0].update(cx, move |pane, cx| {
2274 pane.toolbar()
2275 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2276 });
2277
2278 ProjectSearchView::deploy_search(workspace, &workspace::DeploySearch, cx)
2279 })
2280 .unwrap();
2281
2282 let Some(search_view) = cx.read(|cx| {
2283 workspace
2284 .read(cx)
2285 .unwrap()
2286 .active_pane()
2287 .read(cx)
2288 .active_item()
2289 .and_then(|item| item.downcast::<ProjectSearchView>())
2290 }) else {
2291 panic!("Search view expected to appear after new search event trigger")
2292 };
2293
2294 cx.spawn(|mut cx| async move {
2295 window
2296 .update(&mut cx, |_, cx| {
2297 cx.dispatch_action(ToggleFocus.boxed_clone())
2298 })
2299 .unwrap();
2300 })
2301 .detach();
2302 cx.background_executor.run_until_parked();
2303 window
2304 .update(cx, |_, cx| {
2305 search_view.update(cx, |search_view, cx| {
2306 assert!(
2307 search_view.query_editor.focus_handle(cx).is_focused(cx),
2308 "Empty search view should be focused after the toggle focus event: no results panel to focus on",
2309 );
2310 });
2311 }).unwrap();
2312
2313 window
2314 .update(cx, |_, cx| {
2315 search_view.update(cx, |search_view, cx| {
2316 let query_editor = &search_view.query_editor;
2317 assert!(
2318 query_editor.focus_handle(cx).is_focused(cx),
2319 "Search view should be focused after the new search view is activated",
2320 );
2321 let query_text = query_editor.read(cx).text(cx);
2322 assert!(
2323 query_text.is_empty(),
2324 "New search query should be empty but got '{query_text}'",
2325 );
2326 let results_text = search_view
2327 .results_editor
2328 .update(cx, |editor, cx| editor.display_text(cx));
2329 assert!(
2330 results_text.is_empty(),
2331 "Empty search view should have no results but got '{results_text}'"
2332 );
2333 });
2334 })
2335 .unwrap();
2336
2337 window
2338 .update(cx, |_, cx| {
2339 search_view.update(cx, |search_view, cx| {
2340 search_view.query_editor.update(cx, |query_editor, cx| {
2341 query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", cx)
2342 });
2343 search_view.search(cx);
2344 });
2345 })
2346 .unwrap();
2347 cx.background_executor.run_until_parked();
2348 window
2349 .update(cx, |_, cx| {
2350 search_view.update(cx, |search_view, cx| {
2351 let results_text = search_view
2352 .results_editor
2353 .update(cx, |editor, cx| editor.display_text(cx));
2354 assert!(
2355 results_text.is_empty(),
2356 "Search view for mismatching query should have no results but got '{results_text}'"
2357 );
2358 assert!(
2359 search_view.query_editor.focus_handle(cx).is_focused(cx),
2360 "Search view should be focused after mismatching query had been used in search",
2361 );
2362 });
2363 }).unwrap();
2364
2365 cx.spawn(|mut cx| async move {
2366 window.update(&mut cx, |_, cx| {
2367 cx.dispatch_action(ToggleFocus.boxed_clone())
2368 })
2369 })
2370 .detach();
2371 cx.background_executor.run_until_parked();
2372 window.update(cx, |_, cx| {
2373 search_view.update(cx, |search_view, cx| {
2374 assert!(
2375 search_view.query_editor.focus_handle(cx).is_focused(cx),
2376 "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
2377 );
2378 });
2379 }).unwrap();
2380
2381 window
2382 .update(cx, |_, cx| {
2383 search_view.update(cx, |search_view, cx| {
2384 search_view
2385 .query_editor
2386 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
2387 search_view.search(cx);
2388 });
2389 })
2390 .unwrap();
2391 cx.background_executor.run_until_parked();
2392 window.update(cx, |_, cx| {
2393 search_view.update(cx, |search_view, cx| {
2394 assert_eq!(
2395 search_view
2396 .results_editor
2397 .update(cx, |editor, cx| editor.display_text(cx)),
2398 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2399 "Search view results should match the query"
2400 );
2401 assert!(
2402 search_view.results_editor.focus_handle(cx).is_focused(cx),
2403 "Search view with mismatching query should be focused after search results are available",
2404 );
2405 });
2406 }).unwrap();
2407 cx.spawn(|mut cx| async move {
2408 window
2409 .update(&mut cx, |_, cx| {
2410 cx.dispatch_action(ToggleFocus.boxed_clone())
2411 })
2412 .unwrap();
2413 })
2414 .detach();
2415 cx.background_executor.run_until_parked();
2416 window.update(cx, |_, cx| {
2417 search_view.update(cx, |search_view, cx| {
2418 assert!(
2419 search_view.results_editor.focus_handle(cx).is_focused(cx),
2420 "Search view with matching query should still have its results editor focused after the toggle focus event",
2421 );
2422 });
2423 }).unwrap();
2424
2425 workspace
2426 .update(cx, |workspace, cx| {
2427 ProjectSearchView::deploy_search(workspace, &workspace::DeploySearch, cx)
2428 })
2429 .unwrap();
2430 window.update(cx, |_, cx| {
2431 search_view.update(cx, |search_view, cx| {
2432 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");
2433 assert_eq!(
2434 search_view
2435 .results_editor
2436 .update(cx, |editor, cx| editor.display_text(cx)),
2437 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2438 "Results should be unchanged after search view 2nd open in a row"
2439 );
2440 assert!(
2441 search_view.query_editor.focus_handle(cx).is_focused(cx),
2442 "Focus should be moved into query editor again after search view 2nd open in a row"
2443 );
2444 });
2445 }).unwrap();
2446
2447 cx.spawn(|mut cx| async move {
2448 window
2449 .update(&mut cx, |_, cx| {
2450 cx.dispatch_action(ToggleFocus.boxed_clone())
2451 })
2452 .unwrap();
2453 })
2454 .detach();
2455 cx.background_executor.run_until_parked();
2456 window.update(cx, |_, cx| {
2457 search_view.update(cx, |search_view, cx| {
2458 assert!(
2459 search_view.results_editor.focus_handle(cx).is_focused(cx),
2460 "Search view with matching query should switch focus to the results editor after the toggle focus event",
2461 );
2462 });
2463 }).unwrap();
2464 }
2465
2466 #[gpui::test]
2467 async fn test_new_project_search_focus(cx: &mut TestAppContext) {
2468 init_test(cx);
2469
2470 let fs = FakeFs::new(cx.background_executor.clone());
2471 fs.insert_tree(
2472 "/dir",
2473 json!({
2474 "one.rs": "const ONE: usize = 1;",
2475 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2476 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2477 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2478 }),
2479 )
2480 .await;
2481 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2482 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2483 let workspace = window.clone();
2484 let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
2485
2486 let active_item = cx.read(|cx| {
2487 workspace
2488 .read(cx)
2489 .unwrap()
2490 .active_pane()
2491 .read(cx)
2492 .active_item()
2493 .and_then(|item| item.downcast::<ProjectSearchView>())
2494 });
2495 assert!(
2496 active_item.is_none(),
2497 "Expected no search panel to be active"
2498 );
2499
2500 window
2501 .update(cx, move |workspace, cx| {
2502 assert_eq!(workspace.panes().len(), 1);
2503 workspace.panes()[0].update(cx, move |pane, cx| {
2504 pane.toolbar()
2505 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2506 });
2507
2508 ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2509 })
2510 .unwrap();
2511
2512 let Some(search_view) = cx.read(|cx| {
2513 workspace
2514 .read(cx)
2515 .unwrap()
2516 .active_pane()
2517 .read(cx)
2518 .active_item()
2519 .and_then(|item| item.downcast::<ProjectSearchView>())
2520 }) else {
2521 panic!("Search view expected to appear after new search event trigger")
2522 };
2523
2524 cx.spawn(|mut cx| async move {
2525 window
2526 .update(&mut cx, |_, cx| {
2527 cx.dispatch_action(ToggleFocus.boxed_clone())
2528 })
2529 .unwrap();
2530 })
2531 .detach();
2532 cx.background_executor.run_until_parked();
2533
2534 window.update(cx, |_, cx| {
2535 search_view.update(cx, |search_view, cx| {
2536 assert!(
2537 search_view.query_editor.focus_handle(cx).is_focused(cx),
2538 "Empty search view should be focused after the toggle focus event: no results panel to focus on",
2539 );
2540 });
2541 }).unwrap();
2542
2543 window
2544 .update(cx, |_, cx| {
2545 search_view.update(cx, |search_view, cx| {
2546 let query_editor = &search_view.query_editor;
2547 assert!(
2548 query_editor.focus_handle(cx).is_focused(cx),
2549 "Search view should be focused after the new search view is activated",
2550 );
2551 let query_text = query_editor.read(cx).text(cx);
2552 assert!(
2553 query_text.is_empty(),
2554 "New search query should be empty but got '{query_text}'",
2555 );
2556 let results_text = search_view
2557 .results_editor
2558 .update(cx, |editor, cx| editor.display_text(cx));
2559 assert!(
2560 results_text.is_empty(),
2561 "Empty search view should have no results but got '{results_text}'"
2562 );
2563 });
2564 })
2565 .unwrap();
2566
2567 window
2568 .update(cx, |_, cx| {
2569 search_view.update(cx, |search_view, cx| {
2570 search_view.query_editor.update(cx, |query_editor, cx| {
2571 query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", cx)
2572 });
2573 search_view.search(cx);
2574 });
2575 })
2576 .unwrap();
2577
2578 cx.background_executor.run_until_parked();
2579 window
2580 .update(cx, |_, cx| {
2581 search_view.update(cx, |search_view, cx| {
2582 let results_text = search_view
2583 .results_editor
2584 .update(cx, |editor, cx| editor.display_text(cx));
2585 assert!(
2586 results_text.is_empty(),
2587 "Search view for mismatching query should have no results but got '{results_text}'"
2588 );
2589 assert!(
2590 search_view.query_editor.focus_handle(cx).is_focused(cx),
2591 "Search view should be focused after mismatching query had been used in search",
2592 );
2593 });
2594 })
2595 .unwrap();
2596 cx.spawn(|mut cx| async move {
2597 window.update(&mut cx, |_, cx| {
2598 cx.dispatch_action(ToggleFocus.boxed_clone())
2599 })
2600 })
2601 .detach();
2602 cx.background_executor.run_until_parked();
2603 window.update(cx, |_, cx| {
2604 search_view.update(cx, |search_view, cx| {
2605 assert!(
2606 search_view.query_editor.focus_handle(cx).is_focused(cx),
2607 "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
2608 );
2609 });
2610 }).unwrap();
2611
2612 window
2613 .update(cx, |_, cx| {
2614 search_view.update(cx, |search_view, cx| {
2615 search_view
2616 .query_editor
2617 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
2618 search_view.search(cx);
2619 })
2620 })
2621 .unwrap();
2622 cx.background_executor.run_until_parked();
2623 window.update(cx, |_, cx|
2624 search_view.update(cx, |search_view, cx| {
2625 assert_eq!(
2626 search_view
2627 .results_editor
2628 .update(cx, |editor, cx| editor.display_text(cx)),
2629 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2630 "Search view results should match the query"
2631 );
2632 assert!(
2633 search_view.results_editor.focus_handle(cx).is_focused(cx),
2634 "Search view with mismatching query should be focused after search results are available",
2635 );
2636 })).unwrap();
2637 cx.spawn(|mut cx| async move {
2638 window
2639 .update(&mut cx, |_, cx| {
2640 cx.dispatch_action(ToggleFocus.boxed_clone())
2641 })
2642 .unwrap();
2643 })
2644 .detach();
2645 cx.background_executor.run_until_parked();
2646 window.update(cx, |_, cx| {
2647 search_view.update(cx, |search_view, cx| {
2648 assert!(
2649 search_view.results_editor.focus_handle(cx).is_focused(cx),
2650 "Search view with matching query should still have its results editor focused after the toggle focus event",
2651 );
2652 });
2653 }).unwrap();
2654
2655 workspace
2656 .update(cx, |workspace, cx| {
2657 ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2658 })
2659 .unwrap();
2660 cx.background_executor.run_until_parked();
2661 let Some(search_view_2) = cx.read(|cx| {
2662 workspace
2663 .read(cx)
2664 .unwrap()
2665 .active_pane()
2666 .read(cx)
2667 .active_item()
2668 .and_then(|item| item.downcast::<ProjectSearchView>())
2669 }) else {
2670 panic!("Search view expected to appear after new search event trigger")
2671 };
2672 assert!(
2673 search_view_2 != search_view,
2674 "New search view should be open after `workspace::NewSearch` event"
2675 );
2676
2677 window.update(cx, |_, cx| {
2678 search_view.update(cx, |search_view, cx| {
2679 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO", "First search view should not have an updated query");
2680 assert_eq!(
2681 search_view
2682 .results_editor
2683 .update(cx, |editor, cx| editor.display_text(cx)),
2684 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2685 "Results of the first search view should not update too"
2686 );
2687 assert!(
2688 !search_view.query_editor.focus_handle(cx).is_focused(cx),
2689 "Focus should be moved away from the first search view"
2690 );
2691 });
2692 }).unwrap();
2693
2694 window.update(cx, |_, cx| {
2695 search_view_2.update(cx, |search_view_2, cx| {
2696 assert_eq!(
2697 search_view_2.query_editor.read(cx).text(cx),
2698 "two",
2699 "New search view should get the query from the text cursor was at during the event spawn (first search view's first result)"
2700 );
2701 assert_eq!(
2702 search_view_2
2703 .results_editor
2704 .update(cx, |editor, cx| editor.display_text(cx)),
2705 "",
2706 "No search results should be in the 2nd view yet, as we did not spawn a search for it"
2707 );
2708 assert!(
2709 search_view_2.query_editor.focus_handle(cx).is_focused(cx),
2710 "Focus should be moved into query editor of the new window"
2711 );
2712 });
2713 }).unwrap();
2714
2715 window
2716 .update(cx, |_, cx| {
2717 search_view_2.update(cx, |search_view_2, cx| {
2718 search_view_2
2719 .query_editor
2720 .update(cx, |query_editor, cx| query_editor.set_text("FOUR", cx));
2721 search_view_2.search(cx);
2722 });
2723 })
2724 .unwrap();
2725
2726 cx.background_executor.run_until_parked();
2727 window.update(cx, |_, cx| {
2728 search_view_2.update(cx, |search_view_2, cx| {
2729 assert_eq!(
2730 search_view_2
2731 .results_editor
2732 .update(cx, |editor, cx| editor.display_text(cx)),
2733 "\n\nconst FOUR: usize = one::ONE + three::THREE;",
2734 "New search view with the updated query should have new search results"
2735 );
2736 assert!(
2737 search_view_2.results_editor.focus_handle(cx).is_focused(cx),
2738 "Search view with mismatching query should be focused after search results are available",
2739 );
2740 });
2741 }).unwrap();
2742
2743 cx.spawn(|mut cx| async move {
2744 window
2745 .update(&mut cx, |_, cx| {
2746 cx.dispatch_action(ToggleFocus.boxed_clone())
2747 })
2748 .unwrap();
2749 })
2750 .detach();
2751 cx.background_executor.run_until_parked();
2752 window.update(cx, |_, cx| {
2753 search_view_2.update(cx, |search_view_2, cx| {
2754 assert!(
2755 search_view_2.results_editor.focus_handle(cx).is_focused(cx),
2756 "Search view with matching query should switch focus to the results editor after the toggle focus event",
2757 );
2758 });}).unwrap();
2759 }
2760
2761 #[gpui::test]
2762 async fn test_new_project_search_in_directory(cx: &mut TestAppContext) {
2763 init_test(cx);
2764
2765 let fs = FakeFs::new(cx.background_executor.clone());
2766 fs.insert_tree(
2767 "/dir",
2768 json!({
2769 "a": {
2770 "one.rs": "const ONE: usize = 1;",
2771 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2772 },
2773 "b": {
2774 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2775 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2776 },
2777 }),
2778 )
2779 .await;
2780 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2781 let worktree_id = project.read_with(cx, |project, cx| {
2782 project.worktrees().next().unwrap().read(cx).id()
2783 });
2784 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2785 let workspace = window.root(cx).unwrap();
2786 let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
2787
2788 let active_item = cx.read(|cx| {
2789 workspace
2790 .read(cx)
2791 .active_pane()
2792 .read(cx)
2793 .active_item()
2794 .and_then(|item| item.downcast::<ProjectSearchView>())
2795 });
2796 assert!(
2797 active_item.is_none(),
2798 "Expected no search panel to be active"
2799 );
2800
2801 window
2802 .update(cx, move |workspace, cx| {
2803 assert_eq!(workspace.panes().len(), 1);
2804 workspace.panes()[0].update(cx, move |pane, cx| {
2805 pane.toolbar()
2806 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2807 });
2808 })
2809 .unwrap();
2810
2811 let one_file_entry = cx.update(|cx| {
2812 workspace
2813 .read(cx)
2814 .project()
2815 .read(cx)
2816 .entry_for_path(&(worktree_id, "a/one.rs").into(), cx)
2817 .expect("no entry for /a/one.rs file")
2818 });
2819 assert!(one_file_entry.is_file());
2820 window
2821 .update(cx, |workspace, cx| {
2822 ProjectSearchView::new_search_in_directory(workspace, &one_file_entry, cx)
2823 })
2824 .unwrap();
2825 let active_search_entry = cx.read(|cx| {
2826 workspace
2827 .read(cx)
2828 .active_pane()
2829 .read(cx)
2830 .active_item()
2831 .and_then(|item| item.downcast::<ProjectSearchView>())
2832 });
2833 assert!(
2834 active_search_entry.is_none(),
2835 "Expected no search panel to be active for file entry"
2836 );
2837
2838 let a_dir_entry = cx.update(|cx| {
2839 workspace
2840 .read(cx)
2841 .project()
2842 .read(cx)
2843 .entry_for_path(&(worktree_id, "a").into(), cx)
2844 .expect("no entry for /a/ directory")
2845 });
2846 assert!(a_dir_entry.is_dir());
2847 window
2848 .update(cx, |workspace, cx| {
2849 ProjectSearchView::new_search_in_directory(workspace, &a_dir_entry, cx)
2850 })
2851 .unwrap();
2852
2853 let Some(search_view) = cx.read(|cx| {
2854 workspace
2855 .read(cx)
2856 .active_pane()
2857 .read(cx)
2858 .active_item()
2859 .and_then(|item| item.downcast::<ProjectSearchView>())
2860 }) else {
2861 panic!("Search view expected to appear after new search in directory event trigger")
2862 };
2863 cx.background_executor.run_until_parked();
2864 window
2865 .update(cx, |_, cx| {
2866 search_view.update(cx, |search_view, cx| {
2867 assert!(
2868 search_view.query_editor.focus_handle(cx).is_focused(cx),
2869 "On new search in directory, focus should be moved into query editor"
2870 );
2871 search_view.excluded_files_editor.update(cx, |editor, cx| {
2872 assert!(
2873 editor.display_text(cx).is_empty(),
2874 "New search in directory should not have any excluded files"
2875 );
2876 });
2877 search_view.included_files_editor.update(cx, |editor, cx| {
2878 assert_eq!(
2879 editor.display_text(cx),
2880 a_dir_entry.path.to_str().unwrap(),
2881 "New search in directory should have included dir entry path"
2882 );
2883 });
2884 });
2885 })
2886 .unwrap();
2887 window
2888 .update(cx, |_, cx| {
2889 search_view.update(cx, |search_view, cx| {
2890 search_view
2891 .query_editor
2892 .update(cx, |query_editor, cx| query_editor.set_text("const", cx));
2893 search_view.search(cx);
2894 });
2895 })
2896 .unwrap();
2897 cx.background_executor.run_until_parked();
2898 window
2899 .update(cx, |_, cx| {
2900 search_view.update(cx, |search_view, cx| {
2901 assert_eq!(
2902 search_view
2903 .results_editor
2904 .update(cx, |editor, cx| editor.display_text(cx)),
2905 "\n\nconst ONE: usize = 1;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2906 "New search in directory should have a filter that matches a certain directory"
2907 );
2908 })
2909 })
2910 .unwrap();
2911 }
2912
2913 #[gpui::test]
2914 async fn test_search_query_history(cx: &mut TestAppContext) {
2915 init_test(cx);
2916
2917 let fs = FakeFs::new(cx.background_executor.clone());
2918 fs.insert_tree(
2919 "/dir",
2920 json!({
2921 "one.rs": "const ONE: usize = 1;",
2922 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2923 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2924 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2925 }),
2926 )
2927 .await;
2928 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2929 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2930 let workspace = window.root(cx).unwrap();
2931 let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
2932
2933 window
2934 .update(cx, {
2935 let search_bar = search_bar.clone();
2936 move |workspace, cx| {
2937 assert_eq!(workspace.panes().len(), 1);
2938 workspace.panes()[0].update(cx, move |pane, cx| {
2939 pane.toolbar()
2940 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2941 });
2942
2943 ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2944 }
2945 })
2946 .unwrap();
2947
2948 let search_view = cx.read(|cx| {
2949 workspace
2950 .read(cx)
2951 .active_pane()
2952 .read(cx)
2953 .active_item()
2954 .and_then(|item| item.downcast::<ProjectSearchView>())
2955 .expect("Search view expected to appear after new search event trigger")
2956 });
2957
2958 // Add 3 search items into the history + another unsubmitted one.
2959 window
2960 .update(cx, |_, cx| {
2961 search_view.update(cx, |search_view, cx| {
2962 search_view.search_options = SearchOptions::CASE_SENSITIVE;
2963 search_view
2964 .query_editor
2965 .update(cx, |query_editor, cx| query_editor.set_text("ONE", cx));
2966 search_view.search(cx);
2967 });
2968 })
2969 .unwrap();
2970
2971 cx.background_executor.run_until_parked();
2972 window
2973 .update(cx, |_, cx| {
2974 search_view.update(cx, |search_view, cx| {
2975 search_view
2976 .query_editor
2977 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
2978 search_view.search(cx);
2979 });
2980 })
2981 .unwrap();
2982 cx.background_executor.run_until_parked();
2983 window
2984 .update(cx, |_, cx| {
2985 search_view.update(cx, |search_view, cx| {
2986 search_view
2987 .query_editor
2988 .update(cx, |query_editor, cx| query_editor.set_text("THREE", cx));
2989 search_view.search(cx);
2990 })
2991 })
2992 .unwrap();
2993 cx.background_executor.run_until_parked();
2994 window
2995 .update(cx, |_, cx| {
2996 search_view.update(cx, |search_view, cx| {
2997 search_view.query_editor.update(cx, |query_editor, cx| {
2998 query_editor.set_text("JUST_TEXT_INPUT", cx)
2999 });
3000 })
3001 })
3002 .unwrap();
3003 cx.background_executor.run_until_parked();
3004
3005 // Ensure that the latest input with search settings is active.
3006 window
3007 .update(cx, |_, cx| {
3008 search_view.update(cx, |search_view, cx| {
3009 assert_eq!(
3010 search_view.query_editor.read(cx).text(cx),
3011 "JUST_TEXT_INPUT"
3012 );
3013 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3014 });
3015 })
3016 .unwrap();
3017
3018 // Next history query after the latest should set the query to the empty string.
3019 window
3020 .update(cx, |_, cx| {
3021 search_bar.update(cx, |search_bar, cx| {
3022 search_bar.next_history_query(&NextHistoryQuery, cx);
3023 })
3024 })
3025 .unwrap();
3026 window
3027 .update(cx, |_, cx| {
3028 search_view.update(cx, |search_view, cx| {
3029 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
3030 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3031 });
3032 })
3033 .unwrap();
3034 window
3035 .update(cx, |_, cx| {
3036 search_bar.update(cx, |search_bar, cx| {
3037 search_bar.next_history_query(&NextHistoryQuery, cx);
3038 })
3039 })
3040 .unwrap();
3041 window
3042 .update(cx, |_, cx| {
3043 search_view.update(cx, |search_view, cx| {
3044 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
3045 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3046 });
3047 })
3048 .unwrap();
3049
3050 // First previous query for empty current query should set the query to the latest submitted one.
3051 window
3052 .update(cx, |_, cx| {
3053 search_bar.update(cx, |search_bar, cx| {
3054 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
3055 });
3056 })
3057 .unwrap();
3058 window
3059 .update(cx, |_, cx| {
3060 search_view.update(cx, |search_view, cx| {
3061 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
3062 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3063 });
3064 })
3065 .unwrap();
3066
3067 // Further previous items should go over the history in reverse order.
3068 window
3069 .update(cx, |_, cx| {
3070 search_bar.update(cx, |search_bar, cx| {
3071 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
3072 });
3073 })
3074 .unwrap();
3075 window
3076 .update(cx, |_, cx| {
3077 search_view.update(cx, |search_view, cx| {
3078 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
3079 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3080 });
3081 })
3082 .unwrap();
3083
3084 // Previous items should never go behind the first history item.
3085 window
3086 .update(cx, |_, cx| {
3087 search_bar.update(cx, |search_bar, cx| {
3088 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
3089 });
3090 })
3091 .unwrap();
3092 window
3093 .update(cx, |_, cx| {
3094 search_view.update(cx, |search_view, cx| {
3095 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
3096 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3097 });
3098 })
3099 .unwrap();
3100 window
3101 .update(cx, |_, cx| {
3102 search_bar.update(cx, |search_bar, cx| {
3103 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
3104 });
3105 })
3106 .unwrap();
3107 window
3108 .update(cx, |_, cx| {
3109 search_view.update(cx, |search_view, cx| {
3110 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
3111 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3112 });
3113 })
3114 .unwrap();
3115
3116 // Next items should go over the history in the original order.
3117 window
3118 .update(cx, |_, cx| {
3119 search_bar.update(cx, |search_bar, cx| {
3120 search_bar.next_history_query(&NextHistoryQuery, cx);
3121 });
3122 })
3123 .unwrap();
3124 window
3125 .update(cx, |_, cx| {
3126 search_view.update(cx, |search_view, cx| {
3127 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
3128 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3129 });
3130 })
3131 .unwrap();
3132
3133 window
3134 .update(cx, |_, cx| {
3135 search_view.update(cx, |search_view, cx| {
3136 search_view
3137 .query_editor
3138 .update(cx, |query_editor, cx| query_editor.set_text("TWO_NEW", cx));
3139 search_view.search(cx);
3140 });
3141 })
3142 .unwrap();
3143 cx.background_executor.run_until_parked();
3144 window
3145 .update(cx, |_, cx| {
3146 search_view.update(cx, |search_view, cx| {
3147 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
3148 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3149 });
3150 })
3151 .unwrap();
3152
3153 // New search input should add another entry to history and move the selection to the end of the history.
3154 window
3155 .update(cx, |_, cx| {
3156 search_bar.update(cx, |search_bar, cx| {
3157 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
3158 });
3159 })
3160 .unwrap();
3161 window
3162 .update(cx, |_, cx| {
3163 search_view.update(cx, |search_view, cx| {
3164 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
3165 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3166 });
3167 })
3168 .unwrap();
3169 window
3170 .update(cx, |_, cx| {
3171 search_bar.update(cx, |search_bar, cx| {
3172 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
3173 });
3174 })
3175 .unwrap();
3176 window
3177 .update(cx, |_, cx| {
3178 search_view.update(cx, |search_view, cx| {
3179 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
3180 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3181 });
3182 })
3183 .unwrap();
3184 window
3185 .update(cx, |_, cx| {
3186 search_bar.update(cx, |search_bar, cx| {
3187 search_bar.next_history_query(&NextHistoryQuery, cx);
3188 });
3189 })
3190 .unwrap();
3191 window
3192 .update(cx, |_, cx| {
3193 search_view.update(cx, |search_view, cx| {
3194 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
3195 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3196 });
3197 })
3198 .unwrap();
3199 window
3200 .update(cx, |_, cx| {
3201 search_bar.update(cx, |search_bar, cx| {
3202 search_bar.next_history_query(&NextHistoryQuery, cx);
3203 });
3204 })
3205 .unwrap();
3206 window
3207 .update(cx, |_, cx| {
3208 search_view.update(cx, |search_view, cx| {
3209 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
3210 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3211 });
3212 })
3213 .unwrap();
3214 window
3215 .update(cx, |_, cx| {
3216 search_bar.update(cx, |search_bar, cx| {
3217 search_bar.next_history_query(&NextHistoryQuery, cx);
3218 });
3219 })
3220 .unwrap();
3221 window
3222 .update(cx, |_, cx| {
3223 search_view.update(cx, |search_view, cx| {
3224 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
3225 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3226 });
3227 })
3228 .unwrap();
3229 }
3230
3231 #[gpui::test]
3232 async fn test_deploy_search_with_multiple_panes(cx: &mut TestAppContext) {
3233 init_test(cx);
3234
3235 // Setup 2 panes, both with a file open and one with a project search.
3236 let fs = FakeFs::new(cx.background_executor.clone());
3237 fs.insert_tree(
3238 "/dir",
3239 json!({
3240 "one.rs": "const ONE: usize = 1;",
3241 }),
3242 )
3243 .await;
3244 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
3245 let worktree_id = project.update(cx, |this, cx| {
3246 this.worktrees().next().unwrap().read(cx).id()
3247 });
3248 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
3249 let panes: Vec<_> = window
3250 .update(cx, |this, _| this.panes().to_owned())
3251 .unwrap();
3252 assert_eq!(panes.len(), 1);
3253 let first_pane = panes.get(0).cloned().unwrap();
3254 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 0);
3255 window
3256 .update(cx, |workspace, cx| {
3257 workspace.open_path(
3258 (worktree_id, "one.rs"),
3259 Some(first_pane.downgrade()),
3260 true,
3261 cx,
3262 )
3263 })
3264 .unwrap()
3265 .await
3266 .unwrap();
3267 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3268 let second_pane = window
3269 .update(cx, |workspace, cx| {
3270 workspace.split_and_clone(first_pane.clone(), workspace::SplitDirection::Right, cx)
3271 })
3272 .unwrap()
3273 .unwrap();
3274 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
3275 assert!(window
3276 .update(cx, |_, cx| second_pane
3277 .focus_handle(cx)
3278 .contains_focused(cx))
3279 .unwrap());
3280 let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
3281 window
3282 .update(cx, {
3283 let search_bar = search_bar.clone();
3284 let pane = first_pane.clone();
3285 move |workspace, cx| {
3286 assert_eq!(workspace.panes().len(), 2);
3287 pane.update(cx, move |pane, cx| {
3288 pane.toolbar()
3289 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
3290 });
3291 }
3292 })
3293 .unwrap();
3294
3295 // Add a project search item to the second pane
3296 window
3297 .update(cx, {
3298 let search_bar = search_bar.clone();
3299 let pane = second_pane.clone();
3300 move |workspace, cx| {
3301 assert_eq!(workspace.panes().len(), 2);
3302 pane.update(cx, move |pane, cx| {
3303 pane.toolbar()
3304 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
3305 });
3306
3307 ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
3308 }
3309 })
3310 .unwrap();
3311
3312 cx.run_until_parked();
3313 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 2);
3314 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3315
3316 // Focus the first pane
3317 window
3318 .update(cx, |workspace, cx| {
3319 assert_eq!(workspace.active_pane(), &second_pane);
3320 second_pane.update(cx, |this, cx| {
3321 assert_eq!(this.active_item_index(), 1);
3322 this.activate_prev_item(false, cx);
3323 assert_eq!(this.active_item_index(), 0);
3324 });
3325 workspace.activate_pane_in_direction(workspace::SplitDirection::Left, cx);
3326 })
3327 .unwrap();
3328 window
3329 .update(cx, |workspace, cx| {
3330 assert_eq!(workspace.active_pane(), &first_pane);
3331 assert_eq!(first_pane.read(cx).items_len(), 1);
3332 assert_eq!(second_pane.read(cx).items_len(), 2);
3333 })
3334 .unwrap();
3335
3336 // Deploy a new search
3337 cx.dispatch_action(window.into(), DeploySearch);
3338
3339 // Both panes should now have a project search in them
3340 window
3341 .update(cx, |workspace, cx| {
3342 assert_eq!(workspace.active_pane(), &first_pane);
3343 first_pane.update(cx, |this, _| {
3344 assert_eq!(this.active_item_index(), 1);
3345 assert_eq!(this.items_len(), 2);
3346 });
3347 second_pane.update(cx, |this, cx| {
3348 assert!(!cx.focus_handle().contains_focused(cx));
3349 assert_eq!(this.items_len(), 2);
3350 });
3351 })
3352 .unwrap();
3353
3354 // Focus the second pane's non-search item
3355 window
3356 .update(cx, |_workspace, cx| {
3357 second_pane.update(cx, |pane, cx| pane.activate_next_item(true, cx));
3358 })
3359 .unwrap();
3360
3361 // Deploy a new search
3362 cx.dispatch_action(window.into(), DeploySearch);
3363
3364 // The project search view should now be focused in the second pane
3365 // And the number of items should be unchanged.
3366 window
3367 .update(cx, |_workspace, cx| {
3368 second_pane.update(cx, |pane, _cx| {
3369 assert!(pane
3370 .active_item()
3371 .unwrap()
3372 .downcast::<ProjectSearchView>()
3373 .is_some());
3374
3375 assert_eq!(pane.items_len(), 2);
3376 });
3377 })
3378 .unwrap();
3379 }
3380
3381 pub fn init_test(cx: &mut TestAppContext) {
3382 cx.update(|cx| {
3383 let settings = SettingsStore::test(cx);
3384 cx.set_global(settings);
3385
3386 SemanticIndexSettings::register(cx);
3387
3388 theme::init(theme::LoadThemes::JustBase, cx);
3389
3390 language::init(cx);
3391 client::init_settings(cx);
3392 editor::init(cx);
3393 workspace::init_settings(cx);
3394 Project::init_settings(cx);
3395 super::init(cx);
3396 });
3397 }
3398}