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