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 &["Continue", "Cancel"],
750 )
751 })?;
752
753 if answer.await? == 0 {
754 this.update(&mut cx, |this, _| {
755 this.semantic_permissioned = Some(true);
756 })?;
757 } else {
758 this.update(&mut cx, |this, cx| {
759 this.semantic_permissioned = Some(false);
760 debug_assert_ne!(previous_mode, SearchMode::Semantic, "Tried to re-enable semantic search mode after user modal was rejected");
761 this.activate_search_mode(previous_mode, cx);
762 })?;
763 return anyhow::Ok(());
764 }
765 }
766
767 this.update(&mut cx, |this, cx| {
768 this.index_project(cx);
769 })?;
770
771 anyhow::Ok(())
772 }).detach_and_log_err(cx);
773 }
774 SearchMode::Regex | SearchMode::Text => {
775 self.semantic_state = None;
776 self.active_match_index = None;
777 self.search(cx);
778 }
779 }
780
781 cx.update_global(|state: &mut ActiveSettings, cx| {
782 state.0.insert(
783 self.model.read(cx).project.downgrade(),
784 self.current_settings(),
785 );
786 });
787
788 cx.notify();
789 }
790 fn replace_next(&mut self, _: &ReplaceNext, cx: &mut ViewContext<Self>) {
791 let model = self.model.read(cx);
792 if let Some(query) = model.active_query.as_ref() {
793 if model.match_ranges.is_empty() {
794 return;
795 }
796 if let Some(active_index) = self.active_match_index {
797 let query = query.clone().with_replacement(self.replacement(cx));
798 self.results_editor.replace(
799 &(Box::new(model.match_ranges[active_index].clone()) as _),
800 &query,
801 cx,
802 );
803 self.select_match(Direction::Next, cx)
804 }
805 }
806 }
807 pub fn replacement(&self, cx: &AppContext) -> String {
808 self.replacement_editor.read(cx).text(cx)
809 }
810 fn replace_all(&mut self, _: &ReplaceAll, cx: &mut ViewContext<Self>) {
811 let model = self.model.read(cx);
812 if let Some(query) = model.active_query.as_ref() {
813 if model.match_ranges.is_empty() {
814 return;
815 }
816 if self.active_match_index.is_some() {
817 let query = query.clone().with_replacement(self.replacement(cx));
818 let matches = model
819 .match_ranges
820 .iter()
821 .map(|item| Box::new(item.clone()) as _)
822 .collect::<Vec<_>>();
823 for item in matches {
824 self.results_editor.replace(&item, &query, cx);
825 }
826 }
827 }
828 }
829
830 fn new(
831 model: Model<ProjectSearch>,
832 cx: &mut ViewContext<Self>,
833 settings: Option<ProjectSearchSettings>,
834 ) -> Self {
835 let project;
836 let excerpts;
837 let mut replacement_text = None;
838 let mut query_text = String::new();
839 let mut subscriptions = Vec::new();
840
841 // Read in settings if available
842 let (mut options, current_mode, filters_enabled) = if let Some(settings) = settings {
843 (
844 settings.search_options,
845 settings.current_mode,
846 settings.filters_enabled,
847 )
848 } else {
849 (SearchOptions::NONE, Default::default(), false)
850 };
851
852 {
853 let model = model.read(cx);
854 project = model.project.clone();
855 excerpts = model.excerpts.clone();
856 if let Some(active_query) = model.active_query.as_ref() {
857 query_text = active_query.as_str().to_string();
858 replacement_text = active_query.replacement().map(ToOwned::to_owned);
859 options = SearchOptions::from_query(active_query);
860 }
861 }
862 subscriptions.push(cx.observe(&model, |this, _, cx| this.model_changed(cx)));
863
864 let query_editor = cx.new_view(|cx| {
865 let mut editor = Editor::single_line(cx);
866 editor.set_placeholder_text("Text search all files", cx);
867 editor.set_text(query_text, cx);
868 editor
869 });
870 // Subscribe to query_editor in order to reraise editor events for workspace item activation purposes
871 subscriptions.push(
872 cx.subscribe(&query_editor, |_, _, event: &EditorEvent, cx| {
873 cx.emit(ViewEvent::EditorEvent(event.clone()))
874 }),
875 );
876 let replacement_editor = cx.new_view(|cx| {
877 let mut editor = Editor::single_line(cx);
878 editor.set_placeholder_text("Replace in project..", cx);
879 if let Some(text) = replacement_text {
880 editor.set_text(text, cx);
881 }
882 editor
883 });
884 let results_editor = cx.new_view(|cx| {
885 let mut editor = Editor::for_multibuffer(excerpts, Some(project.clone()), cx);
886 editor.set_searchable(false);
887 editor
888 });
889 subscriptions.push(cx.observe(&results_editor, |_, _, cx| cx.emit(ViewEvent::UpdateTab)));
890
891 subscriptions.push(
892 cx.subscribe(&results_editor, |this, _, event: &EditorEvent, cx| {
893 if matches!(event, editor::EditorEvent::SelectionsChanged { .. }) {
894 this.update_match_index(cx);
895 }
896 // Reraise editor events for workspace item activation purposes
897 cx.emit(ViewEvent::EditorEvent(event.clone()));
898 }),
899 );
900
901 let included_files_editor = cx.new_view(|cx| {
902 let mut editor = Editor::single_line(cx);
903 editor.set_placeholder_text("Include: crates/**/*.toml", cx);
904
905 editor
906 });
907 // Subscribe to include_files_editor in order to reraise editor events for workspace item activation purposes
908 subscriptions.push(
909 cx.subscribe(&included_files_editor, |_, _, event: &EditorEvent, cx| {
910 cx.emit(ViewEvent::EditorEvent(event.clone()))
911 }),
912 );
913
914 let excluded_files_editor = cx.new_view(|cx| {
915 let mut editor = Editor::single_line(cx);
916 editor.set_placeholder_text("Exclude: vendor/*, *.lock", cx);
917
918 editor
919 });
920 // Subscribe to excluded_files_editor in order to reraise editor events for workspace item activation purposes
921 subscriptions.push(
922 cx.subscribe(&excluded_files_editor, |_, _, event: &EditorEvent, cx| {
923 cx.emit(ViewEvent::EditorEvent(event.clone()))
924 }),
925 );
926
927 let focus_handle = cx.focus_handle();
928 subscriptions.push(cx.on_focus_in(&focus_handle, |this, cx| {
929 if this.focus_handle.is_focused(cx) {
930 if this.has_matches() {
931 this.results_editor.focus_handle(cx).focus(cx);
932 } else {
933 this.query_editor.focus_handle(cx).focus(cx);
934 }
935 }
936 }));
937
938 // Check if Worktrees have all been previously indexed
939 let mut this = ProjectSearchView {
940 focus_handle,
941 replacement_editor,
942 search_id: model.read(cx).search_id,
943 model,
944 query_editor,
945 results_editor,
946 semantic_state: None,
947 semantic_permissioned: None,
948 search_options: options,
949 panels_with_errors: HashSet::new(),
950 active_match_index: None,
951 query_editor_was_focused: false,
952 included_files_editor,
953 excluded_files_editor,
954 filters_enabled,
955 current_mode,
956 replace_enabled: false,
957 _subscriptions: subscriptions,
958 };
959 this.model_changed(cx);
960 this
961 }
962
963 fn semantic_permissioned(&mut self, cx: &mut ViewContext<Self>) -> Task<Result<bool>> {
964 if let Some(value) = self.semantic_permissioned {
965 return Task::ready(Ok(value));
966 }
967
968 SemanticIndex::global(cx)
969 .map(|semantic| {
970 let project = self.model.read(cx).project.clone();
971 semantic.update(cx, |this, cx| this.project_previously_indexed(&project, cx))
972 })
973 .unwrap_or(Task::ready(Ok(false)))
974 }
975
976 pub fn new_search_in_directory(
977 workspace: &mut Workspace,
978 dir_entry: &Entry,
979 cx: &mut ViewContext<Workspace>,
980 ) {
981 if !dir_entry.is_dir() {
982 return;
983 }
984 let Some(filter_str) = dir_entry.path.to_str() else {
985 return;
986 };
987
988 let model = cx.new_model(|cx| ProjectSearch::new(workspace.project().clone(), cx));
989 let search = cx.new_view(|cx| ProjectSearchView::new(model, cx, None));
990 workspace.add_item(Box::new(search.clone()), cx);
991 search.update(cx, |search, cx| {
992 search
993 .included_files_editor
994 .update(cx, |editor, cx| editor.set_text(filter_str, cx));
995 search.filters_enabled = true;
996 search.focus_query_editor(cx)
997 });
998 }
999
1000 // Re-activate the most recently activated search in this pane or the most recent if it has been closed.
1001 // If no search exists in the workspace, create a new one.
1002 fn deploy_search(
1003 workspace: &mut Workspace,
1004 _: &workspace::DeploySearch,
1005 cx: &mut ViewContext<Workspace>,
1006 ) {
1007 let existing = workspace
1008 .active_pane()
1009 .read(cx)
1010 .items()
1011 .find_map(|item| item.downcast::<ProjectSearchView>());
1012
1013 Self::existing_or_new_search(workspace, existing, cx)
1014 }
1015
1016 fn search_in_new(workspace: &mut Workspace, _: &SearchInNew, cx: &mut ViewContext<Workspace>) {
1017 if let Some(search_view) = workspace
1018 .active_item(cx)
1019 .and_then(|item| item.downcast::<ProjectSearchView>())
1020 {
1021 let new_query = search_view.update(cx, |search_view, cx| {
1022 let new_query = search_view.build_search_query(cx);
1023 if new_query.is_some() {
1024 if let Some(old_query) = search_view.model.read(cx).active_query.clone() {
1025 search_view.query_editor.update(cx, |editor, cx| {
1026 editor.set_text(old_query.as_str(), cx);
1027 });
1028 search_view.search_options = SearchOptions::from_query(&old_query);
1029 }
1030 }
1031 new_query
1032 });
1033 if let Some(new_query) = new_query {
1034 let model = cx.new_model(|cx| {
1035 let mut model = ProjectSearch::new(workspace.project().clone(), cx);
1036 model.search(new_query, cx);
1037 model
1038 });
1039 workspace.add_item(
1040 Box::new(cx.new_view(|cx| ProjectSearchView::new(model, cx, None))),
1041 cx,
1042 );
1043 }
1044 }
1045 }
1046
1047 // Add another search tab to the workspace.
1048 fn new_search(
1049 workspace: &mut Workspace,
1050 _: &workspace::NewSearch,
1051 cx: &mut ViewContext<Workspace>,
1052 ) {
1053 Self::existing_or_new_search(workspace, None, cx)
1054 }
1055
1056 fn existing_or_new_search(
1057 workspace: &mut Workspace,
1058 existing: Option<View<ProjectSearchView>>,
1059 cx: &mut ViewContext<Workspace>,
1060 ) {
1061 let query = workspace.active_item(cx).and_then(|item| {
1062 let editor = item.act_as::<Editor>(cx)?;
1063 let query = editor.query_suggestion(cx);
1064 if query.is_empty() {
1065 None
1066 } else {
1067 Some(query)
1068 }
1069 });
1070
1071 let search = if let Some(existing) = existing {
1072 workspace.activate_item(&existing, cx);
1073 existing
1074 } else {
1075 let settings = cx
1076 .global::<ActiveSettings>()
1077 .0
1078 .get(&workspace.project().downgrade());
1079
1080 let settings = if let Some(settings) = settings {
1081 Some(settings.clone())
1082 } else {
1083 None
1084 };
1085
1086 let model = cx.new_model(|cx| ProjectSearch::new(workspace.project().clone(), cx));
1087 let view = cx.new_view(|cx| ProjectSearchView::new(model, cx, settings));
1088
1089 workspace.add_item(Box::new(view.clone()), cx);
1090 view
1091 };
1092
1093 search.update(cx, |search, cx| {
1094 if let Some(query) = query {
1095 search.set_query(&query, cx);
1096 }
1097 search.focus_query_editor(cx)
1098 });
1099 }
1100
1101 fn search(&mut self, cx: &mut ViewContext<Self>) {
1102 let mode = self.current_mode;
1103 match mode {
1104 SearchMode::Semantic => {
1105 if self.semantic_state.is_some() {
1106 if let Some(query) = self.build_search_query(cx) {
1107 self.model
1108 .update(cx, |model, cx| model.semantic_search(query.as_inner(), cx));
1109 }
1110 }
1111 }
1112
1113 _ => {
1114 if let Some(query) = self.build_search_query(cx) {
1115 self.model.update(cx, |model, cx| model.search(query, cx));
1116 }
1117 }
1118 }
1119 }
1120
1121 fn build_search_query(&mut self, cx: &mut ViewContext<Self>) -> Option<SearchQuery> {
1122 // Do not bail early in this function, as we want to fill out `self.panels_with_errors`.
1123 let text = self.query_editor.read(cx).text(cx);
1124 let included_files =
1125 match Self::parse_path_matches(&self.included_files_editor.read(cx).text(cx)) {
1126 Ok(included_files) => {
1127 let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Include);
1128 if should_unmark_error {
1129 cx.notify();
1130 }
1131 included_files
1132 }
1133 Err(_e) => {
1134 let should_mark_error = self.panels_with_errors.insert(InputPanel::Include);
1135 if should_mark_error {
1136 cx.notify();
1137 }
1138 vec![]
1139 }
1140 };
1141 let excluded_files =
1142 match Self::parse_path_matches(&self.excluded_files_editor.read(cx).text(cx)) {
1143 Ok(excluded_files) => {
1144 let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Exclude);
1145 if should_unmark_error {
1146 cx.notify();
1147 }
1148
1149 excluded_files
1150 }
1151 Err(_e) => {
1152 let should_mark_error = self.panels_with_errors.insert(InputPanel::Exclude);
1153 if should_mark_error {
1154 cx.notify();
1155 }
1156 vec![]
1157 }
1158 };
1159
1160 let current_mode = self.current_mode;
1161 let query = match current_mode {
1162 SearchMode::Regex => {
1163 match SearchQuery::regex(
1164 text,
1165 self.search_options.contains(SearchOptions::WHOLE_WORD),
1166 self.search_options.contains(SearchOptions::CASE_SENSITIVE),
1167 self.search_options.contains(SearchOptions::INCLUDE_IGNORED),
1168 included_files,
1169 excluded_files,
1170 ) {
1171 Ok(query) => {
1172 let should_unmark_error =
1173 self.panels_with_errors.remove(&InputPanel::Query);
1174 if should_unmark_error {
1175 cx.notify();
1176 }
1177
1178 Some(query)
1179 }
1180 Err(_e) => {
1181 let should_mark_error = self.panels_with_errors.insert(InputPanel::Query);
1182 if should_mark_error {
1183 cx.notify();
1184 }
1185
1186 None
1187 }
1188 }
1189 }
1190 _ => match SearchQuery::text(
1191 text,
1192 self.search_options.contains(SearchOptions::WHOLE_WORD),
1193 self.search_options.contains(SearchOptions::CASE_SENSITIVE),
1194 self.search_options.contains(SearchOptions::INCLUDE_IGNORED),
1195 included_files,
1196 excluded_files,
1197 ) {
1198 Ok(query) => {
1199 let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Query);
1200 if should_unmark_error {
1201 cx.notify();
1202 }
1203
1204 Some(query)
1205 }
1206 Err(_e) => {
1207 let should_mark_error = self.panels_with_errors.insert(InputPanel::Query);
1208 if should_mark_error {
1209 cx.notify();
1210 }
1211
1212 None
1213 }
1214 },
1215 };
1216 if !self.panels_with_errors.is_empty() {
1217 return None;
1218 }
1219 query
1220 }
1221
1222 fn parse_path_matches(text: &str) -> anyhow::Result<Vec<PathMatcher>> {
1223 text.split(',')
1224 .map(str::trim)
1225 .filter(|maybe_glob_str| !maybe_glob_str.is_empty())
1226 .map(|maybe_glob_str| {
1227 PathMatcher::new(maybe_glob_str)
1228 .with_context(|| format!("parsing {maybe_glob_str} as path matcher"))
1229 })
1230 .collect()
1231 }
1232
1233 fn select_match(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
1234 if let Some(index) = self.active_match_index {
1235 let match_ranges = self.model.read(cx).match_ranges.clone();
1236 let new_index = self.results_editor.update(cx, |editor, cx| {
1237 editor.match_index_for_direction(&match_ranges, index, direction, 1, cx)
1238 });
1239
1240 let range_to_select = match_ranges[new_index].clone();
1241 self.results_editor.update(cx, |editor, cx| {
1242 let range_to_select = editor.range_for_match(&range_to_select);
1243 editor.unfold_ranges([range_to_select.clone()], false, true, cx);
1244 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
1245 s.select_ranges([range_to_select])
1246 });
1247 });
1248 }
1249 }
1250
1251 fn focus_query_editor(&mut self, cx: &mut ViewContext<Self>) {
1252 self.query_editor.update(cx, |query_editor, cx| {
1253 query_editor.select_all(&SelectAll, cx);
1254 });
1255 self.query_editor_was_focused = true;
1256 let editor_handle = self.query_editor.focus_handle(cx);
1257 cx.focus(&editor_handle);
1258 }
1259
1260 fn set_query(&mut self, query: &str, cx: &mut ViewContext<Self>) {
1261 self.query_editor
1262 .update(cx, |query_editor, cx| query_editor.set_text(query, cx));
1263 }
1264
1265 fn focus_results_editor(&mut self, cx: &mut ViewContext<Self>) {
1266 self.query_editor.update(cx, |query_editor, cx| {
1267 let cursor = query_editor.selections.newest_anchor().head();
1268 query_editor.change_selections(None, cx, |s| s.select_ranges([cursor.clone()..cursor]));
1269 });
1270 self.query_editor_was_focused = false;
1271 let results_handle = self.results_editor.focus_handle(cx);
1272 cx.focus(&results_handle);
1273 }
1274
1275 fn model_changed(&mut self, cx: &mut ViewContext<Self>) {
1276 let match_ranges = self.model.read(cx).match_ranges.clone();
1277 if match_ranges.is_empty() {
1278 self.active_match_index = None;
1279 } else {
1280 self.active_match_index = Some(0);
1281 self.update_match_index(cx);
1282 let prev_search_id = mem::replace(&mut self.search_id, self.model.read(cx).search_id);
1283 let is_new_search = self.search_id != prev_search_id;
1284 self.results_editor.update(cx, |editor, cx| {
1285 if is_new_search {
1286 let range_to_select = match_ranges
1287 .first()
1288 .clone()
1289 .map(|range| editor.range_for_match(range));
1290 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
1291 s.select_ranges(range_to_select)
1292 });
1293 }
1294 editor.highlight_background::<Self>(
1295 match_ranges,
1296 |theme| theme.search_match_background,
1297 cx,
1298 );
1299 });
1300 if is_new_search && self.query_editor.focus_handle(cx).is_focused(cx) {
1301 self.focus_results_editor(cx);
1302 }
1303 }
1304
1305 cx.emit(ViewEvent::UpdateTab);
1306 cx.notify();
1307 }
1308
1309 fn update_match_index(&mut self, cx: &mut ViewContext<Self>) {
1310 let results_editor = self.results_editor.read(cx);
1311 let new_index = active_match_index(
1312 &self.model.read(cx).match_ranges,
1313 &results_editor.selections.newest_anchor().head(),
1314 &results_editor.buffer().read(cx).snapshot(cx),
1315 );
1316 if self.active_match_index != new_index {
1317 self.active_match_index = new_index;
1318 cx.notify();
1319 }
1320 }
1321
1322 pub fn has_matches(&self) -> bool {
1323 self.active_match_index.is_some()
1324 }
1325
1326 fn landing_text_minor(&self) -> SharedString {
1327 match self.current_mode {
1328 SearchMode::Text | SearchMode::Regex => "Include/exclude specific paths with the filter option. Matching exact word and/or casing is available too.".into(),
1329 SearchMode::Semantic => "\nSimply explain the code you are looking to find. ex. 'prompt user for permissions to index their project'".into()
1330 }
1331 }
1332 fn border_color_for(&self, panel: InputPanel, cx: &WindowContext) -> Hsla {
1333 if self.panels_with_errors.contains(&panel) {
1334 Color::Error.color(cx)
1335 } else {
1336 cx.theme().colors().border
1337 }
1338 }
1339 fn move_focus_to_results(&mut self, cx: &mut ViewContext<Self>) {
1340 if !self.results_editor.focus_handle(cx).is_focused(cx)
1341 && !self.model.read(cx).match_ranges.is_empty()
1342 {
1343 cx.stop_propagation();
1344 return self.focus_results_editor(cx);
1345 }
1346 }
1347}
1348
1349impl ProjectSearchBar {
1350 pub fn new() -> Self {
1351 Self {
1352 active_project_search: None,
1353 subscription: None,
1354 }
1355 }
1356
1357 fn cycle_mode(&self, _: &CycleMode, cx: &mut ViewContext<Self>) {
1358 if let Some(view) = self.active_project_search.as_ref() {
1359 view.update(cx, |this, cx| {
1360 let new_mode =
1361 crate::mode::next_mode(&this.current_mode, SemanticIndex::enabled(cx));
1362 this.activate_search_mode(new_mode, cx);
1363 let editor_handle = this.query_editor.focus_handle(cx);
1364 cx.focus(&editor_handle);
1365 });
1366 }
1367 }
1368
1369 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
1370 if let Some(search_view) = self.active_project_search.as_ref() {
1371 search_view.update(cx, |search_view, cx| {
1372 if !search_view
1373 .replacement_editor
1374 .focus_handle(cx)
1375 .is_focused(cx)
1376 {
1377 cx.stop_propagation();
1378 search_view.search(cx);
1379 }
1380 });
1381 }
1382 }
1383
1384 fn tab(&mut self, _: &editor::actions::Tab, cx: &mut ViewContext<Self>) {
1385 self.cycle_field(Direction::Next, cx);
1386 }
1387
1388 fn tab_previous(&mut self, _: &editor::actions::TabPrev, cx: &mut ViewContext<Self>) {
1389 self.cycle_field(Direction::Prev, cx);
1390 }
1391
1392 fn cycle_field(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
1393 let active_project_search = match &self.active_project_search {
1394 Some(active_project_search) => active_project_search,
1395
1396 None => {
1397 return;
1398 }
1399 };
1400
1401 active_project_search.update(cx, |project_view, cx| {
1402 let mut views = vec![&project_view.query_editor];
1403 if project_view.filters_enabled {
1404 views.extend([
1405 &project_view.included_files_editor,
1406 &project_view.excluded_files_editor,
1407 ]);
1408 }
1409 if project_view.replace_enabled {
1410 views.push(&project_view.replacement_editor);
1411 }
1412 let current_index = match views
1413 .iter()
1414 .enumerate()
1415 .find(|(_, view)| view.focus_handle(cx).is_focused(cx))
1416 {
1417 Some((index, _)) => index,
1418
1419 None => {
1420 return;
1421 }
1422 };
1423
1424 let new_index = match direction {
1425 Direction::Next => (current_index + 1) % views.len(),
1426 Direction::Prev if current_index == 0 => views.len() - 1,
1427 Direction::Prev => (current_index - 1) % views.len(),
1428 };
1429 let next_focus_handle = views[new_index].focus_handle(cx);
1430 cx.focus(&next_focus_handle);
1431 cx.stop_propagation();
1432 });
1433 }
1434
1435 fn toggle_search_option(&mut self, option: SearchOptions, cx: &mut ViewContext<Self>) -> bool {
1436 if let Some(search_view) = self.active_project_search.as_ref() {
1437 search_view.update(cx, |search_view, cx| {
1438 search_view.toggle_search_option(option, cx);
1439 search_view.search(cx);
1440 });
1441
1442 cx.notify();
1443 true
1444 } else {
1445 false
1446 }
1447 }
1448
1449 fn toggle_replace(&mut self, _: &ToggleReplace, cx: &mut ViewContext<Self>) {
1450 if let Some(search) = &self.active_project_search {
1451 search.update(cx, |this, cx| {
1452 this.replace_enabled = !this.replace_enabled;
1453 let editor_to_focus = if !this.replace_enabled {
1454 this.query_editor.focus_handle(cx)
1455 } else {
1456 this.replacement_editor.focus_handle(cx)
1457 };
1458 cx.focus(&editor_to_focus);
1459 cx.notify();
1460 });
1461 }
1462 }
1463
1464 fn toggle_filters(&mut self, cx: &mut ViewContext<Self>) -> bool {
1465 if let Some(search_view) = self.active_project_search.as_ref() {
1466 search_view.update(cx, |search_view, cx| {
1467 search_view.toggle_filters(cx);
1468 search_view
1469 .included_files_editor
1470 .update(cx, |_, cx| cx.notify());
1471 search_view
1472 .excluded_files_editor
1473 .update(cx, |_, cx| cx.notify());
1474 cx.refresh();
1475 cx.notify();
1476 });
1477 cx.notify();
1478 true
1479 } else {
1480 false
1481 }
1482 }
1483
1484 fn move_focus_to_results(&self, cx: &mut ViewContext<Self>) {
1485 if let Some(search_view) = self.active_project_search.as_ref() {
1486 search_view.update(cx, |search_view, cx| {
1487 search_view.move_focus_to_results(cx);
1488 });
1489 cx.notify();
1490 }
1491 }
1492
1493 fn activate_search_mode(&self, mode: SearchMode, cx: &mut ViewContext<Self>) {
1494 // Update Current Mode
1495 if let Some(search_view) = self.active_project_search.as_ref() {
1496 search_view.update(cx, |search_view, cx| {
1497 search_view.activate_search_mode(mode, cx);
1498 });
1499 cx.notify();
1500 }
1501 }
1502
1503 fn is_option_enabled(&self, option: SearchOptions, cx: &AppContext) -> bool {
1504 if let Some(search) = self.active_project_search.as_ref() {
1505 search.read(cx).search_options.contains(option)
1506 } else {
1507 false
1508 }
1509 }
1510
1511 fn next_history_query(&mut self, _: &NextHistoryQuery, cx: &mut ViewContext<Self>) {
1512 if let Some(search_view) = self.active_project_search.as_ref() {
1513 search_view.update(cx, |search_view, cx| {
1514 let new_query = search_view.model.update(cx, |model, _| {
1515 if let Some(new_query) = model.search_history.next().map(str::to_string) {
1516 new_query
1517 } else {
1518 model.search_history.reset_selection();
1519 String::new()
1520 }
1521 });
1522 search_view.set_query(&new_query, cx);
1523 });
1524 }
1525 }
1526
1527 fn previous_history_query(&mut self, _: &PreviousHistoryQuery, cx: &mut ViewContext<Self>) {
1528 if let Some(search_view) = self.active_project_search.as_ref() {
1529 search_view.update(cx, |search_view, cx| {
1530 if search_view.query_editor.read(cx).text(cx).is_empty() {
1531 if let Some(new_query) = search_view
1532 .model
1533 .read(cx)
1534 .search_history
1535 .current()
1536 .map(str::to_string)
1537 {
1538 search_view.set_query(&new_query, cx);
1539 return;
1540 }
1541 }
1542
1543 if let Some(new_query) = search_view.model.update(cx, |model, _| {
1544 model.search_history.previous().map(str::to_string)
1545 }) {
1546 search_view.set_query(&new_query, cx);
1547 }
1548 });
1549 }
1550 }
1551
1552 pub fn select_next_match(&mut self, _: &SelectNextMatch, cx: &mut ViewContext<Self>) {
1553 if let Some(search) = self.active_project_search.as_ref() {
1554 search.update(cx, |this, cx| {
1555 this.select_match(Direction::Next, cx);
1556 })
1557 }
1558 }
1559
1560 fn select_prev_match(&mut self, _: &SelectPrevMatch, cx: &mut ViewContext<Self>) {
1561 if let Some(search) = self.active_project_search.as_ref() {
1562 search.update(cx, |this, cx| {
1563 this.select_match(Direction::Prev, cx);
1564 })
1565 }
1566 }
1567
1568 fn new_placeholder_text(&self, cx: &mut ViewContext<Self>) -> Option<String> {
1569 let previous_query_keystrokes = cx
1570 .bindings_for_action(&PreviousHistoryQuery {})
1571 .into_iter()
1572 .next()
1573 .map(|binding| {
1574 binding
1575 .keystrokes()
1576 .iter()
1577 .map(|k| k.to_string())
1578 .collect::<Vec<_>>()
1579 });
1580 let next_query_keystrokes = cx
1581 .bindings_for_action(&NextHistoryQuery {})
1582 .into_iter()
1583 .next()
1584 .map(|binding| {
1585 binding
1586 .keystrokes()
1587 .iter()
1588 .map(|k| k.to_string())
1589 .collect::<Vec<_>>()
1590 });
1591 let new_placeholder_text = match (previous_query_keystrokes, next_query_keystrokes) {
1592 (Some(previous_query_keystrokes), Some(next_query_keystrokes)) => Some(format!(
1593 "Search ({}/{} for previous/next query)",
1594 previous_query_keystrokes.join(" "),
1595 next_query_keystrokes.join(" ")
1596 )),
1597 (None, Some(next_query_keystrokes)) => Some(format!(
1598 "Search ({} for next query)",
1599 next_query_keystrokes.join(" ")
1600 )),
1601 (Some(previous_query_keystrokes), None) => Some(format!(
1602 "Search ({} for previous query)",
1603 previous_query_keystrokes.join(" ")
1604 )),
1605 (None, None) => None,
1606 };
1607 new_placeholder_text
1608 }
1609
1610 fn render_text_input(&self, editor: &View<Editor>, cx: &ViewContext<Self>) -> impl IntoElement {
1611 let settings = ThemeSettings::get_global(cx);
1612 let text_style = TextStyle {
1613 color: if editor.read(cx).read_only(cx) {
1614 cx.theme().colors().text_disabled
1615 } else {
1616 cx.theme().colors().text
1617 },
1618 font_family: settings.ui_font.family.clone(),
1619 font_features: settings.ui_font.features,
1620 font_size: rems(0.875).into(),
1621 font_weight: FontWeight::NORMAL,
1622 font_style: FontStyle::Normal,
1623 line_height: relative(1.3).into(),
1624 background_color: None,
1625 underline: None,
1626 white_space: WhiteSpace::Normal,
1627 };
1628
1629 EditorElement::new(
1630 &editor,
1631 EditorStyle {
1632 background: cx.theme().colors().editor_background,
1633 local_player: cx.theme().players().local(),
1634 text: text_style,
1635 ..Default::default()
1636 },
1637 )
1638 }
1639}
1640
1641impl Render for ProjectSearchBar {
1642 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1643 let Some(search) = self.active_project_search.clone() else {
1644 return div();
1645 };
1646 let mut key_context = KeyContext::default();
1647 key_context.add("ProjectSearchBar");
1648 if let Some(placeholder_text) = self.new_placeholder_text(cx) {
1649 search.update(cx, |search, cx| {
1650 search.query_editor.update(cx, |this, cx| {
1651 this.set_placeholder_text(placeholder_text, cx)
1652 })
1653 });
1654 }
1655 let search = search.read(cx);
1656 let semantic_is_available = SemanticIndex::enabled(cx);
1657
1658 let query_column = v_flex().child(
1659 h_flex()
1660 .min_w(rems(512. / 16.))
1661 .px_2()
1662 .py_1()
1663 .gap_2()
1664 .bg(cx.theme().colors().editor_background)
1665 .border_1()
1666 .border_color(search.border_color_for(InputPanel::Query, cx))
1667 .rounded_lg()
1668 .on_action(cx.listener(|this, action, cx| this.confirm(action, cx)))
1669 .on_action(cx.listener(|this, action, cx| this.previous_history_query(action, cx)))
1670 .on_action(cx.listener(|this, action, cx| this.next_history_query(action, cx)))
1671 .child(Icon::new(IconName::MagnifyingGlass))
1672 .child(self.render_text_input(&search.query_editor, cx))
1673 .child(
1674 h_flex()
1675 .child(
1676 IconButton::new("project-search-filter-button", IconName::Filter)
1677 .tooltip(|cx| {
1678 Tooltip::for_action("Toggle filters", &ToggleFilters, cx)
1679 })
1680 .on_click(cx.listener(|this, _, cx| {
1681 this.toggle_filters(cx);
1682 }))
1683 .selected(
1684 self.active_project_search
1685 .as_ref()
1686 .map(|search| search.read(cx).filters_enabled)
1687 .unwrap_or_default(),
1688 ),
1689 )
1690 .when(search.current_mode != SearchMode::Semantic, |this| {
1691 this.child(
1692 IconButton::new(
1693 "project-search-case-sensitive",
1694 IconName::CaseSensitive,
1695 )
1696 .tooltip(|cx| {
1697 Tooltip::for_action(
1698 "Toggle case sensitive",
1699 &ToggleCaseSensitive,
1700 cx,
1701 )
1702 })
1703 .selected(self.is_option_enabled(SearchOptions::CASE_SENSITIVE, cx))
1704 .on_click(cx.listener(
1705 |this, _, cx| {
1706 this.toggle_search_option(
1707 SearchOptions::CASE_SENSITIVE,
1708 cx,
1709 );
1710 },
1711 )),
1712 )
1713 .child(
1714 IconButton::new("project-search-whole-word", IconName::WholeWord)
1715 .tooltip(|cx| {
1716 Tooltip::for_action(
1717 "Toggle whole word",
1718 &ToggleWholeWord,
1719 cx,
1720 )
1721 })
1722 .selected(self.is_option_enabled(SearchOptions::WHOLE_WORD, cx))
1723 .on_click(cx.listener(|this, _, cx| {
1724 this.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
1725 })),
1726 )
1727 }),
1728 ),
1729 );
1730
1731 let mode_column = v_flex().items_start().justify_start().child(
1732 h_flex()
1733 .gap_2()
1734 .child(
1735 h_flex()
1736 .child(
1737 ToggleButton::new("project-search-text-button", "Text")
1738 .style(ButtonStyle::Filled)
1739 .size(ButtonSize::Large)
1740 .selected(search.current_mode == SearchMode::Text)
1741 .on_click(cx.listener(|this, _, cx| {
1742 this.activate_search_mode(SearchMode::Text, cx)
1743 }))
1744 .tooltip(|cx| {
1745 Tooltip::for_action("Toggle text search", &ActivateTextMode, cx)
1746 })
1747 .first(),
1748 )
1749 .child(
1750 ToggleButton::new("project-search-regex-button", "Regex")
1751 .style(ButtonStyle::Filled)
1752 .size(ButtonSize::Large)
1753 .selected(search.current_mode == SearchMode::Regex)
1754 .on_click(cx.listener(|this, _, cx| {
1755 this.activate_search_mode(SearchMode::Regex, cx)
1756 }))
1757 .tooltip(|cx| {
1758 Tooltip::for_action(
1759 "Toggle regular expression search",
1760 &ActivateRegexMode,
1761 cx,
1762 )
1763 })
1764 .map(|this| {
1765 if semantic_is_available {
1766 this.middle()
1767 } else {
1768 this.last()
1769 }
1770 }),
1771 )
1772 .when(semantic_is_available, |this| {
1773 this.child(
1774 ToggleButton::new("project-search-semantic-button", "Semantic")
1775 .style(ButtonStyle::Filled)
1776 .size(ButtonSize::Large)
1777 .selected(search.current_mode == SearchMode::Semantic)
1778 .on_click(cx.listener(|this, _, cx| {
1779 this.activate_search_mode(SearchMode::Semantic, cx)
1780 }))
1781 .tooltip(|cx| {
1782 Tooltip::for_action(
1783 "Toggle semantic search",
1784 &ActivateSemanticMode,
1785 cx,
1786 )
1787 })
1788 .last(),
1789 )
1790 }),
1791 )
1792 .child(
1793 IconButton::new("project-search-toggle-replace", IconName::Replace)
1794 .on_click(cx.listener(|this, _, cx| {
1795 this.toggle_replace(&ToggleReplace, cx);
1796 }))
1797 .tooltip(|cx| Tooltip::for_action("Toggle replace", &ToggleReplace, cx)),
1798 ),
1799 );
1800 let replace_column = if search.replace_enabled {
1801 h_flex()
1802 .flex_1()
1803 .h_full()
1804 .gap_2()
1805 .px_2()
1806 .py_1()
1807 .border_1()
1808 .border_color(cx.theme().colors().border)
1809 .rounded_lg()
1810 .child(Icon::new(IconName::Replace).size(ui::IconSize::Small))
1811 .child(self.render_text_input(&search.replacement_editor, cx))
1812 } else {
1813 // Fill out the space if we don't have a replacement editor.
1814 h_flex().flex_1()
1815 };
1816 let actions_column = h_flex()
1817 .when(search.replace_enabled, |this| {
1818 this.child(
1819 IconButton::new("project-search-replace-next", IconName::ReplaceNext)
1820 .on_click(cx.listener(|this, _, cx| {
1821 if let Some(search) = this.active_project_search.as_ref() {
1822 search.update(cx, |this, cx| {
1823 this.replace_next(&ReplaceNext, cx);
1824 })
1825 }
1826 }))
1827 .tooltip(|cx| Tooltip::for_action("Replace next match", &ReplaceNext, cx)),
1828 )
1829 .child(
1830 IconButton::new("project-search-replace-all", IconName::ReplaceAll)
1831 .on_click(cx.listener(|this, _, cx| {
1832 if let Some(search) = this.active_project_search.as_ref() {
1833 search.update(cx, |this, cx| {
1834 this.replace_all(&ReplaceAll, cx);
1835 })
1836 }
1837 }))
1838 .tooltip(|cx| Tooltip::for_action("Replace all matches", &ReplaceAll, cx)),
1839 )
1840 })
1841 .when_some(search.active_match_index, |mut this, index| {
1842 let index = index + 1;
1843 let match_quantity = search.model.read(cx).match_ranges.len();
1844 if match_quantity > 0 {
1845 debug_assert!(match_quantity >= index);
1846 this = this.child(Label::new(format!("{index}/{match_quantity}")))
1847 }
1848 this
1849 })
1850 .child(
1851 IconButton::new("project-search-prev-match", IconName::ChevronLeft)
1852 .disabled(search.active_match_index.is_none())
1853 .on_click(cx.listener(|this, _, cx| {
1854 if let Some(search) = this.active_project_search.as_ref() {
1855 search.update(cx, |this, cx| {
1856 this.select_match(Direction::Prev, cx);
1857 })
1858 }
1859 }))
1860 .tooltip(|cx| {
1861 Tooltip::for_action("Go to previous match", &SelectPrevMatch, cx)
1862 }),
1863 )
1864 .child(
1865 IconButton::new("project-search-next-match", IconName::ChevronRight)
1866 .disabled(search.active_match_index.is_none())
1867 .on_click(cx.listener(|this, _, cx| {
1868 if let Some(search) = this.active_project_search.as_ref() {
1869 search.update(cx, |this, cx| {
1870 this.select_match(Direction::Next, cx);
1871 })
1872 }
1873 }))
1874 .tooltip(|cx| Tooltip::for_action("Go to next match", &SelectNextMatch, cx)),
1875 );
1876
1877 v_flex()
1878 .key_context(key_context)
1879 .flex_grow()
1880 .gap_2()
1881 .on_action(cx.listener(|this, _: &ToggleFocus, cx| this.move_focus_to_results(cx)))
1882 .on_action(cx.listener(|this, _: &ToggleFilters, cx| {
1883 this.toggle_filters(cx);
1884 }))
1885 .on_action(cx.listener(|this, _: &ActivateTextMode, cx| {
1886 this.activate_search_mode(SearchMode::Text, cx)
1887 }))
1888 .on_action(cx.listener(|this, _: &ActivateRegexMode, cx| {
1889 this.activate_search_mode(SearchMode::Regex, cx)
1890 }))
1891 .on_action(cx.listener(|this, _: &ActivateSemanticMode, cx| {
1892 this.activate_search_mode(SearchMode::Semantic, cx)
1893 }))
1894 .capture_action(cx.listener(|this, action, cx| {
1895 this.tab(action, cx);
1896 cx.stop_propagation();
1897 }))
1898 .capture_action(cx.listener(|this, action, cx| {
1899 this.tab_previous(action, cx);
1900 cx.stop_propagation();
1901 }))
1902 .on_action(cx.listener(|this, action, cx| this.confirm(action, cx)))
1903 .on_action(cx.listener(|this, action, cx| {
1904 this.cycle_mode(action, cx);
1905 }))
1906 .when(search.current_mode != SearchMode::Semantic, |this| {
1907 this.on_action(cx.listener(|this, action, cx| {
1908 this.toggle_replace(action, cx);
1909 }))
1910 .on_action(cx.listener(|this, _: &ToggleWholeWord, cx| {
1911 this.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
1912 }))
1913 .on_action(cx.listener(|this, _: &ToggleCaseSensitive, cx| {
1914 this.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
1915 }))
1916 .on_action(cx.listener(|this, action, cx| {
1917 if let Some(search) = this.active_project_search.as_ref() {
1918 search.update(cx, |this, cx| {
1919 this.replace_next(action, cx);
1920 })
1921 }
1922 }))
1923 .on_action(cx.listener(|this, action, cx| {
1924 if let Some(search) = this.active_project_search.as_ref() {
1925 search.update(cx, |this, cx| {
1926 this.replace_all(action, cx);
1927 })
1928 }
1929 }))
1930 .when(search.filters_enabled, |this| {
1931 this.on_action(cx.listener(|this, _: &ToggleIncludeIgnored, cx| {
1932 this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, cx);
1933 }))
1934 })
1935 })
1936 .on_action(cx.listener(Self::select_next_match))
1937 .on_action(cx.listener(Self::select_prev_match))
1938 .child(
1939 h_flex()
1940 .justify_between()
1941 .gap_2()
1942 .child(query_column)
1943 .child(mode_column)
1944 .child(replace_column)
1945 .child(actions_column),
1946 )
1947 .when(search.filters_enabled, |this| {
1948 this.child(
1949 h_flex()
1950 .flex_1()
1951 .gap_2()
1952 .justify_between()
1953 .child(
1954 h_flex()
1955 .flex_1()
1956 .h_full()
1957 .px_2()
1958 .py_1()
1959 .border_1()
1960 .border_color(search.border_color_for(InputPanel::Include, cx))
1961 .rounded_lg()
1962 .child(self.render_text_input(&search.included_files_editor, cx))
1963 .when(search.current_mode != SearchMode::Semantic, |this| {
1964 this.child(
1965 SearchOptions::INCLUDE_IGNORED.as_button(
1966 search
1967 .search_options
1968 .contains(SearchOptions::INCLUDE_IGNORED),
1969 cx.listener(|this, _, cx| {
1970 this.toggle_search_option(
1971 SearchOptions::INCLUDE_IGNORED,
1972 cx,
1973 );
1974 }),
1975 ),
1976 )
1977 }),
1978 )
1979 .child(
1980 h_flex()
1981 .flex_1()
1982 .h_full()
1983 .px_2()
1984 .py_1()
1985 .border_1()
1986 .border_color(search.border_color_for(InputPanel::Exclude, cx))
1987 .rounded_lg()
1988 .child(self.render_text_input(&search.excluded_files_editor, cx)),
1989 ),
1990 )
1991 })
1992 }
1993}
1994
1995impl EventEmitter<ToolbarItemEvent> for ProjectSearchBar {}
1996
1997impl ToolbarItemView for ProjectSearchBar {
1998 fn set_active_pane_item(
1999 &mut self,
2000 active_pane_item: Option<&dyn ItemHandle>,
2001 cx: &mut ViewContext<Self>,
2002 ) -> ToolbarItemLocation {
2003 cx.notify();
2004 self.subscription = None;
2005 self.active_project_search = None;
2006 if let Some(search) = active_pane_item.and_then(|i| i.downcast::<ProjectSearchView>()) {
2007 search.update(cx, |search, cx| {
2008 if search.current_mode == SearchMode::Semantic {
2009 search.index_project(cx);
2010 }
2011 });
2012
2013 self.subscription = Some(cx.observe(&search, |_, _, cx| cx.notify()));
2014 self.active_project_search = Some(search);
2015 ToolbarItemLocation::PrimaryLeft {}
2016 } else {
2017 ToolbarItemLocation::Hidden
2018 }
2019 }
2020
2021 fn row_count(&self, cx: &WindowContext<'_>) -> usize {
2022 if let Some(search) = self.active_project_search.as_ref() {
2023 if search.read(cx).filters_enabled {
2024 return 2;
2025 }
2026 }
2027 1
2028 }
2029}
2030
2031fn register_workspace_action<A: Action>(
2032 workspace: &mut Workspace,
2033 callback: fn(&mut ProjectSearchBar, &A, &mut ViewContext<ProjectSearchBar>),
2034) {
2035 workspace.register_action(move |workspace, action: &A, cx| {
2036 if workspace.has_active_modal(cx) {
2037 cx.propagate();
2038 return;
2039 }
2040
2041 workspace.active_pane().update(cx, |pane, cx| {
2042 pane.toolbar().update(cx, move |workspace, cx| {
2043 if let Some(search_bar) = workspace.item_of_type::<ProjectSearchBar>() {
2044 search_bar.update(cx, move |search_bar, cx| {
2045 if search_bar.active_project_search.is_some() {
2046 callback(search_bar, action, cx);
2047 cx.notify();
2048 } else {
2049 cx.propagate();
2050 }
2051 });
2052 }
2053 });
2054 })
2055 });
2056}
2057
2058fn register_workspace_action_for_present_search<A: Action>(
2059 workspace: &mut Workspace,
2060 callback: fn(&mut Workspace, &A, &mut ViewContext<Workspace>),
2061) {
2062 workspace.register_action(move |workspace, action: &A, cx| {
2063 if workspace.has_active_modal(cx) {
2064 cx.propagate();
2065 return;
2066 }
2067
2068 let should_notify = workspace
2069 .active_pane()
2070 .read(cx)
2071 .toolbar()
2072 .read(cx)
2073 .item_of_type::<ProjectSearchBar>()
2074 .map(|search_bar| search_bar.read(cx).active_project_search.is_some())
2075 .unwrap_or(false);
2076 if should_notify {
2077 callback(workspace, action, cx);
2078 cx.notify();
2079 } else {
2080 cx.propagate();
2081 }
2082 });
2083}
2084
2085#[cfg(test)]
2086pub mod tests {
2087 use super::*;
2088 use editor::DisplayPoint;
2089 use gpui::{Action, TestAppContext};
2090 use project::FakeFs;
2091 use semantic_index::semantic_index_settings::SemanticIndexSettings;
2092 use serde_json::json;
2093 use settings::{Settings, SettingsStore};
2094 use workspace::DeploySearch;
2095
2096 #[gpui::test]
2097 async fn test_project_search(cx: &mut TestAppContext) {
2098 init_test(cx);
2099
2100 let fs = FakeFs::new(cx.background_executor.clone());
2101 fs.insert_tree(
2102 "/dir",
2103 json!({
2104 "one.rs": "const ONE: usize = 1;",
2105 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2106 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2107 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2108 }),
2109 )
2110 .await;
2111 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2112 let search = cx.new_model(|cx| ProjectSearch::new(project, cx));
2113 let search_view = cx.add_window(|cx| ProjectSearchView::new(search.clone(), cx, None));
2114
2115 search_view
2116 .update(cx, |search_view, cx| {
2117 search_view
2118 .query_editor
2119 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
2120 search_view.search(cx);
2121 })
2122 .unwrap();
2123 cx.background_executor.run_until_parked();
2124 search_view.update(cx, |search_view, cx| {
2125 assert_eq!(
2126 search_view
2127 .results_editor
2128 .update(cx, |editor, cx| editor.display_text(cx)),
2129 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;"
2130 );
2131 let match_background_color = cx.theme().colors().search_match_background;
2132 assert_eq!(
2133 search_view
2134 .results_editor
2135 .update(cx, |editor, cx| editor.all_text_background_highlights(cx)),
2136 &[
2137 (
2138 DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35),
2139 match_background_color
2140 ),
2141 (
2142 DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40),
2143 match_background_color
2144 ),
2145 (
2146 DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9),
2147 match_background_color
2148 )
2149 ]
2150 );
2151 assert_eq!(search_view.active_match_index, Some(0));
2152 assert_eq!(
2153 search_view
2154 .results_editor
2155 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2156 [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
2157 );
2158
2159 search_view.select_match(Direction::Next, cx);
2160 }).unwrap();
2161
2162 search_view
2163 .update(cx, |search_view, cx| {
2164 assert_eq!(search_view.active_match_index, Some(1));
2165 assert_eq!(
2166 search_view
2167 .results_editor
2168 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2169 [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
2170 );
2171 search_view.select_match(Direction::Next, cx);
2172 })
2173 .unwrap();
2174
2175 search_view
2176 .update(cx, |search_view, cx| {
2177 assert_eq!(search_view.active_match_index, Some(2));
2178 assert_eq!(
2179 search_view
2180 .results_editor
2181 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2182 [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
2183 );
2184 search_view.select_match(Direction::Next, cx);
2185 })
2186 .unwrap();
2187
2188 search_view
2189 .update(cx, |search_view, cx| {
2190 assert_eq!(search_view.active_match_index, Some(0));
2191 assert_eq!(
2192 search_view
2193 .results_editor
2194 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2195 [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
2196 );
2197 search_view.select_match(Direction::Prev, cx);
2198 })
2199 .unwrap();
2200
2201 search_view
2202 .update(cx, |search_view, cx| {
2203 assert_eq!(search_view.active_match_index, Some(2));
2204 assert_eq!(
2205 search_view
2206 .results_editor
2207 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2208 [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
2209 );
2210 search_view.select_match(Direction::Prev, cx);
2211 })
2212 .unwrap();
2213
2214 search_view
2215 .update(cx, |search_view, cx| {
2216 assert_eq!(search_view.active_match_index, Some(1));
2217 assert_eq!(
2218 search_view
2219 .results_editor
2220 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2221 [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
2222 );
2223 })
2224 .unwrap();
2225 }
2226
2227 #[gpui::test]
2228 async fn test_deploy_project_search_focus(cx: &mut TestAppContext) {
2229 init_test(cx);
2230
2231 let fs = FakeFs::new(cx.background_executor.clone());
2232 fs.insert_tree(
2233 "/dir",
2234 json!({
2235 "one.rs": "const ONE: usize = 1;",
2236 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2237 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2238 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2239 }),
2240 )
2241 .await;
2242 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2243 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2244 let workspace = window.clone();
2245 let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
2246
2247 let active_item = cx.read(|cx| {
2248 workspace
2249 .read(cx)
2250 .unwrap()
2251 .active_pane()
2252 .read(cx)
2253 .active_item()
2254 .and_then(|item| item.downcast::<ProjectSearchView>())
2255 });
2256 assert!(
2257 active_item.is_none(),
2258 "Expected no search panel to be active"
2259 );
2260
2261 window
2262 .update(cx, move |workspace, cx| {
2263 assert_eq!(workspace.panes().len(), 1);
2264 workspace.panes()[0].update(cx, move |pane, cx| {
2265 pane.toolbar()
2266 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2267 });
2268
2269 ProjectSearchView::deploy_search(workspace, &workspace::DeploySearch, cx)
2270 })
2271 .unwrap();
2272
2273 let Some(search_view) = cx.read(|cx| {
2274 workspace
2275 .read(cx)
2276 .unwrap()
2277 .active_pane()
2278 .read(cx)
2279 .active_item()
2280 .and_then(|item| item.downcast::<ProjectSearchView>())
2281 }) else {
2282 panic!("Search view expected to appear after new search event trigger")
2283 };
2284
2285 cx.spawn(|mut cx| async move {
2286 window
2287 .update(&mut cx, |_, cx| {
2288 cx.dispatch_action(ToggleFocus.boxed_clone())
2289 })
2290 .unwrap();
2291 })
2292 .detach();
2293 cx.background_executor.run_until_parked();
2294 window
2295 .update(cx, |_, cx| {
2296 search_view.update(cx, |search_view, cx| {
2297 assert!(
2298 search_view.query_editor.focus_handle(cx).is_focused(cx),
2299 "Empty search view should be focused after the toggle focus event: no results panel to focus on",
2300 );
2301 });
2302 }).unwrap();
2303
2304 window
2305 .update(cx, |_, cx| {
2306 search_view.update(cx, |search_view, cx| {
2307 let query_editor = &search_view.query_editor;
2308 assert!(
2309 query_editor.focus_handle(cx).is_focused(cx),
2310 "Search view should be focused after the new search view is activated",
2311 );
2312 let query_text = query_editor.read(cx).text(cx);
2313 assert!(
2314 query_text.is_empty(),
2315 "New search query should be empty but got '{query_text}'",
2316 );
2317 let results_text = search_view
2318 .results_editor
2319 .update(cx, |editor, cx| editor.display_text(cx));
2320 assert!(
2321 results_text.is_empty(),
2322 "Empty search view should have no results but got '{results_text}'"
2323 );
2324 });
2325 })
2326 .unwrap();
2327
2328 window
2329 .update(cx, |_, cx| {
2330 search_view.update(cx, |search_view, cx| {
2331 search_view.query_editor.update(cx, |query_editor, cx| {
2332 query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", cx)
2333 });
2334 search_view.search(cx);
2335 });
2336 })
2337 .unwrap();
2338 cx.background_executor.run_until_parked();
2339 window
2340 .update(cx, |_, cx| {
2341 search_view.update(cx, |search_view, cx| {
2342 let results_text = search_view
2343 .results_editor
2344 .update(cx, |editor, cx| editor.display_text(cx));
2345 assert!(
2346 results_text.is_empty(),
2347 "Search view for mismatching query should have no results but got '{results_text}'"
2348 );
2349 assert!(
2350 search_view.query_editor.focus_handle(cx).is_focused(cx),
2351 "Search view should be focused after mismatching query had been used in search",
2352 );
2353 });
2354 }).unwrap();
2355
2356 cx.spawn(|mut cx| async move {
2357 window.update(&mut cx, |_, cx| {
2358 cx.dispatch_action(ToggleFocus.boxed_clone())
2359 })
2360 })
2361 .detach();
2362 cx.background_executor.run_until_parked();
2363 window.update(cx, |_, cx| {
2364 search_view.update(cx, |search_view, cx| {
2365 assert!(
2366 search_view.query_editor.focus_handle(cx).is_focused(cx),
2367 "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
2368 );
2369 });
2370 }).unwrap();
2371
2372 window
2373 .update(cx, |_, cx| {
2374 search_view.update(cx, |search_view, cx| {
2375 search_view
2376 .query_editor
2377 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
2378 search_view.search(cx);
2379 });
2380 })
2381 .unwrap();
2382 cx.background_executor.run_until_parked();
2383 window.update(cx, |_, cx| {
2384 search_view.update(cx, |search_view, cx| {
2385 assert_eq!(
2386 search_view
2387 .results_editor
2388 .update(cx, |editor, cx| editor.display_text(cx)),
2389 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2390 "Search view results should match the query"
2391 );
2392 assert!(
2393 search_view.results_editor.focus_handle(cx).is_focused(cx),
2394 "Search view with mismatching query should be focused after search results are available",
2395 );
2396 });
2397 }).unwrap();
2398 cx.spawn(|mut cx| async move {
2399 window
2400 .update(&mut cx, |_, cx| {
2401 cx.dispatch_action(ToggleFocus.boxed_clone())
2402 })
2403 .unwrap();
2404 })
2405 .detach();
2406 cx.background_executor.run_until_parked();
2407 window.update(cx, |_, cx| {
2408 search_view.update(cx, |search_view, cx| {
2409 assert!(
2410 search_view.results_editor.focus_handle(cx).is_focused(cx),
2411 "Search view with matching query should still have its results editor focused after the toggle focus event",
2412 );
2413 });
2414 }).unwrap();
2415
2416 workspace
2417 .update(cx, |workspace, cx| {
2418 ProjectSearchView::deploy_search(workspace, &workspace::DeploySearch, cx)
2419 })
2420 .unwrap();
2421 window.update(cx, |_, cx| {
2422 search_view.update(cx, |search_view, cx| {
2423 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");
2424 assert_eq!(
2425 search_view
2426 .results_editor
2427 .update(cx, |editor, cx| editor.display_text(cx)),
2428 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2429 "Results should be unchanged after search view 2nd open in a row"
2430 );
2431 assert!(
2432 search_view.query_editor.focus_handle(cx).is_focused(cx),
2433 "Focus should be moved into query editor again after search view 2nd open in a row"
2434 );
2435 });
2436 }).unwrap();
2437
2438 cx.spawn(|mut cx| async move {
2439 window
2440 .update(&mut cx, |_, cx| {
2441 cx.dispatch_action(ToggleFocus.boxed_clone())
2442 })
2443 .unwrap();
2444 })
2445 .detach();
2446 cx.background_executor.run_until_parked();
2447 window.update(cx, |_, cx| {
2448 search_view.update(cx, |search_view, cx| {
2449 assert!(
2450 search_view.results_editor.focus_handle(cx).is_focused(cx),
2451 "Search view with matching query should switch focus to the results editor after the toggle focus event",
2452 );
2453 });
2454 }).unwrap();
2455 }
2456
2457 #[gpui::test]
2458 async fn test_new_project_search_focus(cx: &mut TestAppContext) {
2459 init_test(cx);
2460
2461 let fs = FakeFs::new(cx.background_executor.clone());
2462 fs.insert_tree(
2463 "/dir",
2464 json!({
2465 "one.rs": "const ONE: usize = 1;",
2466 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2467 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2468 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2469 }),
2470 )
2471 .await;
2472 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2473 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2474 let workspace = window.clone();
2475 let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
2476
2477 let active_item = cx.read(|cx| {
2478 workspace
2479 .read(cx)
2480 .unwrap()
2481 .active_pane()
2482 .read(cx)
2483 .active_item()
2484 .and_then(|item| item.downcast::<ProjectSearchView>())
2485 });
2486 assert!(
2487 active_item.is_none(),
2488 "Expected no search panel to be active"
2489 );
2490
2491 window
2492 .update(cx, move |workspace, cx| {
2493 assert_eq!(workspace.panes().len(), 1);
2494 workspace.panes()[0].update(cx, move |pane, cx| {
2495 pane.toolbar()
2496 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2497 });
2498
2499 ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2500 })
2501 .unwrap();
2502
2503 let Some(search_view) = cx.read(|cx| {
2504 workspace
2505 .read(cx)
2506 .unwrap()
2507 .active_pane()
2508 .read(cx)
2509 .active_item()
2510 .and_then(|item| item.downcast::<ProjectSearchView>())
2511 }) else {
2512 panic!("Search view expected to appear after new search event trigger")
2513 };
2514
2515 cx.spawn(|mut cx| async move {
2516 window
2517 .update(&mut cx, |_, cx| {
2518 cx.dispatch_action(ToggleFocus.boxed_clone())
2519 })
2520 .unwrap();
2521 })
2522 .detach();
2523 cx.background_executor.run_until_parked();
2524
2525 window.update(cx, |_, cx| {
2526 search_view.update(cx, |search_view, cx| {
2527 assert!(
2528 search_view.query_editor.focus_handle(cx).is_focused(cx),
2529 "Empty search view should be focused after the toggle focus event: no results panel to focus on",
2530 );
2531 });
2532 }).unwrap();
2533
2534 window
2535 .update(cx, |_, cx| {
2536 search_view.update(cx, |search_view, cx| {
2537 let query_editor = &search_view.query_editor;
2538 assert!(
2539 query_editor.focus_handle(cx).is_focused(cx),
2540 "Search view should be focused after the new search view is activated",
2541 );
2542 let query_text = query_editor.read(cx).text(cx);
2543 assert!(
2544 query_text.is_empty(),
2545 "New search query should be empty but got '{query_text}'",
2546 );
2547 let results_text = search_view
2548 .results_editor
2549 .update(cx, |editor, cx| editor.display_text(cx));
2550 assert!(
2551 results_text.is_empty(),
2552 "Empty search view should have no results but got '{results_text}'"
2553 );
2554 });
2555 })
2556 .unwrap();
2557
2558 window
2559 .update(cx, |_, cx| {
2560 search_view.update(cx, |search_view, cx| {
2561 search_view.query_editor.update(cx, |query_editor, cx| {
2562 query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", cx)
2563 });
2564 search_view.search(cx);
2565 });
2566 })
2567 .unwrap();
2568
2569 cx.background_executor.run_until_parked();
2570 window
2571 .update(cx, |_, cx| {
2572 search_view.update(cx, |search_view, cx| {
2573 let results_text = search_view
2574 .results_editor
2575 .update(cx, |editor, cx| editor.display_text(cx));
2576 assert!(
2577 results_text.is_empty(),
2578 "Search view for mismatching query should have no results but got '{results_text}'"
2579 );
2580 assert!(
2581 search_view.query_editor.focus_handle(cx).is_focused(cx),
2582 "Search view should be focused after mismatching query had been used in search",
2583 );
2584 });
2585 })
2586 .unwrap();
2587 cx.spawn(|mut cx| async move {
2588 window.update(&mut cx, |_, cx| {
2589 cx.dispatch_action(ToggleFocus.boxed_clone())
2590 })
2591 })
2592 .detach();
2593 cx.background_executor.run_until_parked();
2594 window.update(cx, |_, cx| {
2595 search_view.update(cx, |search_view, cx| {
2596 assert!(
2597 search_view.query_editor.focus_handle(cx).is_focused(cx),
2598 "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
2599 );
2600 });
2601 }).unwrap();
2602
2603 window
2604 .update(cx, |_, cx| {
2605 search_view.update(cx, |search_view, cx| {
2606 search_view
2607 .query_editor
2608 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
2609 search_view.search(cx);
2610 })
2611 })
2612 .unwrap();
2613 cx.background_executor.run_until_parked();
2614 window.update(cx, |_, cx|
2615 search_view.update(cx, |search_view, cx| {
2616 assert_eq!(
2617 search_view
2618 .results_editor
2619 .update(cx, |editor, cx| editor.display_text(cx)),
2620 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2621 "Search view results should match the query"
2622 );
2623 assert!(
2624 search_view.results_editor.focus_handle(cx).is_focused(cx),
2625 "Search view with mismatching query should be focused after search results are available",
2626 );
2627 })).unwrap();
2628 cx.spawn(|mut cx| async move {
2629 window
2630 .update(&mut cx, |_, cx| {
2631 cx.dispatch_action(ToggleFocus.boxed_clone())
2632 })
2633 .unwrap();
2634 })
2635 .detach();
2636 cx.background_executor.run_until_parked();
2637 window.update(cx, |_, cx| {
2638 search_view.update(cx, |search_view, cx| {
2639 assert!(
2640 search_view.results_editor.focus_handle(cx).is_focused(cx),
2641 "Search view with matching query should still have its results editor focused after the toggle focus event",
2642 );
2643 });
2644 }).unwrap();
2645
2646 workspace
2647 .update(cx, |workspace, cx| {
2648 ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2649 })
2650 .unwrap();
2651 cx.background_executor.run_until_parked();
2652 let Some(search_view_2) = cx.read(|cx| {
2653 workspace
2654 .read(cx)
2655 .unwrap()
2656 .active_pane()
2657 .read(cx)
2658 .active_item()
2659 .and_then(|item| item.downcast::<ProjectSearchView>())
2660 }) else {
2661 panic!("Search view expected to appear after new search event trigger")
2662 };
2663 assert!(
2664 search_view_2 != search_view,
2665 "New search view should be open after `workspace::NewSearch` event"
2666 );
2667
2668 window.update(cx, |_, cx| {
2669 search_view.update(cx, |search_view, cx| {
2670 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO", "First search view should not have an updated query");
2671 assert_eq!(
2672 search_view
2673 .results_editor
2674 .update(cx, |editor, cx| editor.display_text(cx)),
2675 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2676 "Results of the first search view should not update too"
2677 );
2678 assert!(
2679 !search_view.query_editor.focus_handle(cx).is_focused(cx),
2680 "Focus should be moved away from the first search view"
2681 );
2682 });
2683 }).unwrap();
2684
2685 window.update(cx, |_, cx| {
2686 search_view_2.update(cx, |search_view_2, cx| {
2687 assert_eq!(
2688 search_view_2.query_editor.read(cx).text(cx),
2689 "two",
2690 "New search view should get the query from the text cursor was at during the event spawn (first search view's first result)"
2691 );
2692 assert_eq!(
2693 search_view_2
2694 .results_editor
2695 .update(cx, |editor, cx| editor.display_text(cx)),
2696 "",
2697 "No search results should be in the 2nd view yet, as we did not spawn a search for it"
2698 );
2699 assert!(
2700 search_view_2.query_editor.focus_handle(cx).is_focused(cx),
2701 "Focus should be moved into query editor of the new window"
2702 );
2703 });
2704 }).unwrap();
2705
2706 window
2707 .update(cx, |_, cx| {
2708 search_view_2.update(cx, |search_view_2, cx| {
2709 search_view_2
2710 .query_editor
2711 .update(cx, |query_editor, cx| query_editor.set_text("FOUR", cx));
2712 search_view_2.search(cx);
2713 });
2714 })
2715 .unwrap();
2716
2717 cx.background_executor.run_until_parked();
2718 window.update(cx, |_, cx| {
2719 search_view_2.update(cx, |search_view_2, cx| {
2720 assert_eq!(
2721 search_view_2
2722 .results_editor
2723 .update(cx, |editor, cx| editor.display_text(cx)),
2724 "\n\nconst FOUR: usize = one::ONE + three::THREE;",
2725 "New search view with the updated query should have new search results"
2726 );
2727 assert!(
2728 search_view_2.results_editor.focus_handle(cx).is_focused(cx),
2729 "Search view with mismatching query should be focused after search results are available",
2730 );
2731 });
2732 }).unwrap();
2733
2734 cx.spawn(|mut cx| async move {
2735 window
2736 .update(&mut cx, |_, cx| {
2737 cx.dispatch_action(ToggleFocus.boxed_clone())
2738 })
2739 .unwrap();
2740 })
2741 .detach();
2742 cx.background_executor.run_until_parked();
2743 window.update(cx, |_, cx| {
2744 search_view_2.update(cx, |search_view_2, cx| {
2745 assert!(
2746 search_view_2.results_editor.focus_handle(cx).is_focused(cx),
2747 "Search view with matching query should switch focus to the results editor after the toggle focus event",
2748 );
2749 });}).unwrap();
2750 }
2751
2752 #[gpui::test]
2753 async fn test_new_project_search_in_directory(cx: &mut TestAppContext) {
2754 init_test(cx);
2755
2756 let fs = FakeFs::new(cx.background_executor.clone());
2757 fs.insert_tree(
2758 "/dir",
2759 json!({
2760 "a": {
2761 "one.rs": "const ONE: usize = 1;",
2762 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2763 },
2764 "b": {
2765 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2766 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2767 },
2768 }),
2769 )
2770 .await;
2771 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2772 let worktree_id = project.read_with(cx, |project, cx| {
2773 project.worktrees().next().unwrap().read(cx).id()
2774 });
2775 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2776 let workspace = window.root(cx).unwrap();
2777 let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
2778
2779 let active_item = cx.read(|cx| {
2780 workspace
2781 .read(cx)
2782 .active_pane()
2783 .read(cx)
2784 .active_item()
2785 .and_then(|item| item.downcast::<ProjectSearchView>())
2786 });
2787 assert!(
2788 active_item.is_none(),
2789 "Expected no search panel to be active"
2790 );
2791
2792 window
2793 .update(cx, move |workspace, cx| {
2794 assert_eq!(workspace.panes().len(), 1);
2795 workspace.panes()[0].update(cx, move |pane, cx| {
2796 pane.toolbar()
2797 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2798 });
2799 })
2800 .unwrap();
2801
2802 let one_file_entry = cx.update(|cx| {
2803 workspace
2804 .read(cx)
2805 .project()
2806 .read(cx)
2807 .entry_for_path(&(worktree_id, "a/one.rs").into(), cx)
2808 .expect("no entry for /a/one.rs file")
2809 });
2810 assert!(one_file_entry.is_file());
2811 window
2812 .update(cx, |workspace, cx| {
2813 ProjectSearchView::new_search_in_directory(workspace, &one_file_entry, cx)
2814 })
2815 .unwrap();
2816 let active_search_entry = cx.read(|cx| {
2817 workspace
2818 .read(cx)
2819 .active_pane()
2820 .read(cx)
2821 .active_item()
2822 .and_then(|item| item.downcast::<ProjectSearchView>())
2823 });
2824 assert!(
2825 active_search_entry.is_none(),
2826 "Expected no search panel to be active for file entry"
2827 );
2828
2829 let a_dir_entry = cx.update(|cx| {
2830 workspace
2831 .read(cx)
2832 .project()
2833 .read(cx)
2834 .entry_for_path(&(worktree_id, "a").into(), cx)
2835 .expect("no entry for /a/ directory")
2836 });
2837 assert!(a_dir_entry.is_dir());
2838 window
2839 .update(cx, |workspace, cx| {
2840 ProjectSearchView::new_search_in_directory(workspace, &a_dir_entry, cx)
2841 })
2842 .unwrap();
2843
2844 let Some(search_view) = cx.read(|cx| {
2845 workspace
2846 .read(cx)
2847 .active_pane()
2848 .read(cx)
2849 .active_item()
2850 .and_then(|item| item.downcast::<ProjectSearchView>())
2851 }) else {
2852 panic!("Search view expected to appear after new search in directory event trigger")
2853 };
2854 cx.background_executor.run_until_parked();
2855 window
2856 .update(cx, |_, cx| {
2857 search_view.update(cx, |search_view, cx| {
2858 assert!(
2859 search_view.query_editor.focus_handle(cx).is_focused(cx),
2860 "On new search in directory, focus should be moved into query editor"
2861 );
2862 search_view.excluded_files_editor.update(cx, |editor, cx| {
2863 assert!(
2864 editor.display_text(cx).is_empty(),
2865 "New search in directory should not have any excluded files"
2866 );
2867 });
2868 search_view.included_files_editor.update(cx, |editor, cx| {
2869 assert_eq!(
2870 editor.display_text(cx),
2871 a_dir_entry.path.to_str().unwrap(),
2872 "New search in directory should have included dir entry path"
2873 );
2874 });
2875 });
2876 })
2877 .unwrap();
2878 window
2879 .update(cx, |_, cx| {
2880 search_view.update(cx, |search_view, cx| {
2881 search_view
2882 .query_editor
2883 .update(cx, |query_editor, cx| query_editor.set_text("const", cx));
2884 search_view.search(cx);
2885 });
2886 })
2887 .unwrap();
2888 cx.background_executor.run_until_parked();
2889 window
2890 .update(cx, |_, cx| {
2891 search_view.update(cx, |search_view, cx| {
2892 assert_eq!(
2893 search_view
2894 .results_editor
2895 .update(cx, |editor, cx| editor.display_text(cx)),
2896 "\n\nconst ONE: usize = 1;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2897 "New search in directory should have a filter that matches a certain directory"
2898 );
2899 })
2900 })
2901 .unwrap();
2902 }
2903
2904 #[gpui::test]
2905 async fn test_search_query_history(cx: &mut TestAppContext) {
2906 init_test(cx);
2907
2908 let fs = FakeFs::new(cx.background_executor.clone());
2909 fs.insert_tree(
2910 "/dir",
2911 json!({
2912 "one.rs": "const ONE: usize = 1;",
2913 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2914 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2915 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2916 }),
2917 )
2918 .await;
2919 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2920 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2921 let workspace = window.root(cx).unwrap();
2922 let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
2923
2924 window
2925 .update(cx, {
2926 let search_bar = search_bar.clone();
2927 move |workspace, cx| {
2928 assert_eq!(workspace.panes().len(), 1);
2929 workspace.panes()[0].update(cx, move |pane, cx| {
2930 pane.toolbar()
2931 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2932 });
2933
2934 ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2935 }
2936 })
2937 .unwrap();
2938
2939 let search_view = cx.read(|cx| {
2940 workspace
2941 .read(cx)
2942 .active_pane()
2943 .read(cx)
2944 .active_item()
2945 .and_then(|item| item.downcast::<ProjectSearchView>())
2946 .expect("Search view expected to appear after new search event trigger")
2947 });
2948
2949 // Add 3 search items into the history + another unsubmitted one.
2950 window
2951 .update(cx, |_, cx| {
2952 search_view.update(cx, |search_view, cx| {
2953 search_view.search_options = SearchOptions::CASE_SENSITIVE;
2954 search_view
2955 .query_editor
2956 .update(cx, |query_editor, cx| query_editor.set_text("ONE", cx));
2957 search_view.search(cx);
2958 });
2959 })
2960 .unwrap();
2961
2962 cx.background_executor.run_until_parked();
2963 window
2964 .update(cx, |_, cx| {
2965 search_view.update(cx, |search_view, cx| {
2966 search_view
2967 .query_editor
2968 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
2969 search_view.search(cx);
2970 });
2971 })
2972 .unwrap();
2973 cx.background_executor.run_until_parked();
2974 window
2975 .update(cx, |_, cx| {
2976 search_view.update(cx, |search_view, cx| {
2977 search_view
2978 .query_editor
2979 .update(cx, |query_editor, cx| query_editor.set_text("THREE", cx));
2980 search_view.search(cx);
2981 })
2982 })
2983 .unwrap();
2984 cx.background_executor.run_until_parked();
2985 window
2986 .update(cx, |_, cx| {
2987 search_view.update(cx, |search_view, cx| {
2988 search_view.query_editor.update(cx, |query_editor, cx| {
2989 query_editor.set_text("JUST_TEXT_INPUT", cx)
2990 });
2991 })
2992 })
2993 .unwrap();
2994 cx.background_executor.run_until_parked();
2995
2996 // Ensure that the latest input with search settings is active.
2997 window
2998 .update(cx, |_, cx| {
2999 search_view.update(cx, |search_view, cx| {
3000 assert_eq!(
3001 search_view.query_editor.read(cx).text(cx),
3002 "JUST_TEXT_INPUT"
3003 );
3004 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3005 });
3006 })
3007 .unwrap();
3008
3009 // Next history query after the latest should set the query to the empty string.
3010 window
3011 .update(cx, |_, cx| {
3012 search_bar.update(cx, |search_bar, cx| {
3013 search_bar.next_history_query(&NextHistoryQuery, cx);
3014 })
3015 })
3016 .unwrap();
3017 window
3018 .update(cx, |_, cx| {
3019 search_view.update(cx, |search_view, cx| {
3020 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
3021 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3022 });
3023 })
3024 .unwrap();
3025 window
3026 .update(cx, |_, cx| {
3027 search_bar.update(cx, |search_bar, cx| {
3028 search_bar.next_history_query(&NextHistoryQuery, cx);
3029 })
3030 })
3031 .unwrap();
3032 window
3033 .update(cx, |_, cx| {
3034 search_view.update(cx, |search_view, cx| {
3035 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
3036 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3037 });
3038 })
3039 .unwrap();
3040
3041 // First previous query for empty current query should set the query to the latest submitted one.
3042 window
3043 .update(cx, |_, cx| {
3044 search_bar.update(cx, |search_bar, cx| {
3045 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
3046 });
3047 })
3048 .unwrap();
3049 window
3050 .update(cx, |_, cx| {
3051 search_view.update(cx, |search_view, cx| {
3052 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
3053 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3054 });
3055 })
3056 .unwrap();
3057
3058 // Further previous items should go over the history in reverse order.
3059 window
3060 .update(cx, |_, cx| {
3061 search_bar.update(cx, |search_bar, cx| {
3062 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
3063 });
3064 })
3065 .unwrap();
3066 window
3067 .update(cx, |_, cx| {
3068 search_view.update(cx, |search_view, cx| {
3069 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
3070 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3071 });
3072 })
3073 .unwrap();
3074
3075 // Previous items should never go behind the first history item.
3076 window
3077 .update(cx, |_, cx| {
3078 search_bar.update(cx, |search_bar, cx| {
3079 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
3080 });
3081 })
3082 .unwrap();
3083 window
3084 .update(cx, |_, cx| {
3085 search_view.update(cx, |search_view, cx| {
3086 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
3087 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3088 });
3089 })
3090 .unwrap();
3091 window
3092 .update(cx, |_, cx| {
3093 search_bar.update(cx, |search_bar, cx| {
3094 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
3095 });
3096 })
3097 .unwrap();
3098 window
3099 .update(cx, |_, cx| {
3100 search_view.update(cx, |search_view, cx| {
3101 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
3102 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3103 });
3104 })
3105 .unwrap();
3106
3107 // Next items should go over the history in the original order.
3108 window
3109 .update(cx, |_, cx| {
3110 search_bar.update(cx, |search_bar, cx| {
3111 search_bar.next_history_query(&NextHistoryQuery, cx);
3112 });
3113 })
3114 .unwrap();
3115 window
3116 .update(cx, |_, cx| {
3117 search_view.update(cx, |search_view, cx| {
3118 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
3119 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3120 });
3121 })
3122 .unwrap();
3123
3124 window
3125 .update(cx, |_, cx| {
3126 search_view.update(cx, |search_view, cx| {
3127 search_view
3128 .query_editor
3129 .update(cx, |query_editor, cx| query_editor.set_text("TWO_NEW", cx));
3130 search_view.search(cx);
3131 });
3132 })
3133 .unwrap();
3134 cx.background_executor.run_until_parked();
3135 window
3136 .update(cx, |_, cx| {
3137 search_view.update(cx, |search_view, cx| {
3138 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
3139 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3140 });
3141 })
3142 .unwrap();
3143
3144 // New search input should add another entry to history and move the selection to the end of the history.
3145 window
3146 .update(cx, |_, cx| {
3147 search_bar.update(cx, |search_bar, cx| {
3148 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
3149 });
3150 })
3151 .unwrap();
3152 window
3153 .update(cx, |_, cx| {
3154 search_view.update(cx, |search_view, cx| {
3155 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
3156 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3157 });
3158 })
3159 .unwrap();
3160 window
3161 .update(cx, |_, cx| {
3162 search_bar.update(cx, |search_bar, cx| {
3163 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
3164 });
3165 })
3166 .unwrap();
3167 window
3168 .update(cx, |_, cx| {
3169 search_view.update(cx, |search_view, cx| {
3170 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
3171 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3172 });
3173 })
3174 .unwrap();
3175 window
3176 .update(cx, |_, cx| {
3177 search_bar.update(cx, |search_bar, cx| {
3178 search_bar.next_history_query(&NextHistoryQuery, cx);
3179 });
3180 })
3181 .unwrap();
3182 window
3183 .update(cx, |_, cx| {
3184 search_view.update(cx, |search_view, cx| {
3185 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
3186 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3187 });
3188 })
3189 .unwrap();
3190 window
3191 .update(cx, |_, cx| {
3192 search_bar.update(cx, |search_bar, cx| {
3193 search_bar.next_history_query(&NextHistoryQuery, cx);
3194 });
3195 })
3196 .unwrap();
3197 window
3198 .update(cx, |_, cx| {
3199 search_view.update(cx, |search_view, cx| {
3200 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
3201 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3202 });
3203 })
3204 .unwrap();
3205 window
3206 .update(cx, |_, cx| {
3207 search_bar.update(cx, |search_bar, cx| {
3208 search_bar.next_history_query(&NextHistoryQuery, cx);
3209 });
3210 })
3211 .unwrap();
3212 window
3213 .update(cx, |_, cx| {
3214 search_view.update(cx, |search_view, cx| {
3215 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
3216 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3217 });
3218 })
3219 .unwrap();
3220 }
3221
3222 #[gpui::test]
3223 async fn test_deploy_search_with_multiple_panes(cx: &mut TestAppContext) {
3224 init_test(cx);
3225
3226 // Setup 2 panes, both with a file open and one with a project search.
3227 let fs = FakeFs::new(cx.background_executor.clone());
3228 fs.insert_tree(
3229 "/dir",
3230 json!({
3231 "one.rs": "const ONE: usize = 1;",
3232 }),
3233 )
3234 .await;
3235 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
3236 let worktree_id = project.update(cx, |this, cx| {
3237 this.worktrees().next().unwrap().read(cx).id()
3238 });
3239 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
3240 let panes: Vec<_> = window
3241 .update(cx, |this, _| this.panes().to_owned())
3242 .unwrap();
3243 assert_eq!(panes.len(), 1);
3244 let first_pane = panes.get(0).cloned().unwrap();
3245 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 0);
3246 window
3247 .update(cx, |workspace, cx| {
3248 workspace.open_path(
3249 (worktree_id, "one.rs"),
3250 Some(first_pane.downgrade()),
3251 true,
3252 cx,
3253 )
3254 })
3255 .unwrap()
3256 .await
3257 .unwrap();
3258 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3259 let second_pane = window
3260 .update(cx, |workspace, cx| {
3261 workspace.split_and_clone(first_pane.clone(), workspace::SplitDirection::Right, cx)
3262 })
3263 .unwrap()
3264 .unwrap();
3265 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
3266 assert!(window
3267 .update(cx, |_, cx| second_pane
3268 .focus_handle(cx)
3269 .contains_focused(cx))
3270 .unwrap());
3271 let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
3272 window
3273 .update(cx, {
3274 let search_bar = search_bar.clone();
3275 let pane = first_pane.clone();
3276 move |workspace, cx| {
3277 assert_eq!(workspace.panes().len(), 2);
3278 pane.update(cx, move |pane, cx| {
3279 pane.toolbar()
3280 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
3281 });
3282 }
3283 })
3284 .unwrap();
3285
3286 // Add a project search item to the second pane
3287 window
3288 .update(cx, {
3289 let search_bar = search_bar.clone();
3290 let pane = second_pane.clone();
3291 move |workspace, cx| {
3292 assert_eq!(workspace.panes().len(), 2);
3293 pane.update(cx, move |pane, cx| {
3294 pane.toolbar()
3295 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
3296 });
3297
3298 ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
3299 }
3300 })
3301 .unwrap();
3302
3303 cx.run_until_parked();
3304 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 2);
3305 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3306
3307 // Focus the first pane
3308 window
3309 .update(cx, |workspace, cx| {
3310 assert_eq!(workspace.active_pane(), &second_pane);
3311 second_pane.update(cx, |this, cx| {
3312 assert_eq!(this.active_item_index(), 1);
3313 this.activate_prev_item(false, cx);
3314 assert_eq!(this.active_item_index(), 0);
3315 });
3316 workspace.activate_pane_in_direction(workspace::SplitDirection::Left, cx);
3317 })
3318 .unwrap();
3319 window
3320 .update(cx, |workspace, cx| {
3321 assert_eq!(workspace.active_pane(), &first_pane);
3322 assert_eq!(first_pane.read(cx).items_len(), 1);
3323 assert_eq!(second_pane.read(cx).items_len(), 2);
3324 })
3325 .unwrap();
3326
3327 // Deploy a new search
3328 cx.dispatch_action(window.into(), DeploySearch);
3329
3330 // Both panes should now have a project search in them
3331 window
3332 .update(cx, |workspace, cx| {
3333 assert_eq!(workspace.active_pane(), &first_pane);
3334 first_pane.update(cx, |this, _| {
3335 assert_eq!(this.active_item_index(), 1);
3336 assert_eq!(this.items_len(), 2);
3337 });
3338 second_pane.update(cx, |this, cx| {
3339 assert!(!cx.focus_handle().contains_focused(cx));
3340 assert_eq!(this.items_len(), 2);
3341 });
3342 })
3343 .unwrap();
3344
3345 // Focus the second pane's non-search item
3346 window
3347 .update(cx, |_workspace, cx| {
3348 second_pane.update(cx, |pane, cx| pane.activate_next_item(true, cx));
3349 })
3350 .unwrap();
3351
3352 // Deploy a new search
3353 cx.dispatch_action(window.into(), DeploySearch);
3354
3355 // The project search view should now be focused in the second pane
3356 // And the number of items should be unchanged.
3357 window
3358 .update(cx, |_workspace, cx| {
3359 second_pane.update(cx, |pane, _cx| {
3360 assert!(pane
3361 .active_item()
3362 .unwrap()
3363 .downcast::<ProjectSearchView>()
3364 .is_some());
3365
3366 assert_eq!(pane.items_len(), 2);
3367 });
3368 })
3369 .unwrap();
3370 }
3371
3372 pub fn init_test(cx: &mut TestAppContext) {
3373 cx.update(|cx| {
3374 let settings = SettingsStore::test(cx);
3375 cx.set_global(settings);
3376
3377 SemanticIndexSettings::register(cx);
3378
3379 theme::init(theme::LoadThemes::JustBase, cx);
3380
3381 language::init(cx);
3382 client::init_settings(cx);
3383 editor::init(cx);
3384 workspace::init_settings(cx);
3385 Project::init_settings(cx);
3386 super::init(cx);
3387 });
3388 }
3389}