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