1use crate::{
2 BufferSearchBar, FocusSearch, HighlightKey, NextHistoryQuery, PreviousHistoryQuery, ReplaceAll,
3 ReplaceNext, SearchOption, SearchOptions, SearchSource, SelectNextMatch, SelectPreviousMatch,
4 ToggleCaseSensitive, ToggleIncludeIgnored, ToggleRegex, ToggleReplace, ToggleWholeWord,
5 buffer_search::Deploy,
6 search_bar::{
7 ActionButtonState, HistoryNavigationDirection, alignment_element, input_base_styles,
8 render_action_button, render_text_input, should_navigate_history,
9 },
10};
11use anyhow::Context as _;
12use collections::{HashMap, HashSet};
13use editor::{
14 Anchor, Editor, EditorEvent, EditorSettings, MAX_TAB_TITLE_LEN, MultiBuffer, PathKey,
15 SelectionEffects,
16 actions::{Backtab, FoldAll, SelectAll, Tab, UnfoldAll},
17 items::active_match_index,
18 multibuffer_context_lines,
19 scroll::Autoscroll,
20};
21use futures::{StreamExt, stream::FuturesOrdered};
22use gpui::{
23 Action, AnyElement, App, Axis, Context, Entity, EntityId, EventEmitter, FocusHandle, Focusable,
24 Global, Hsla, InteractiveElement, IntoElement, KeyContext, ParentElement, Point, Render,
25 SharedString, Styled, Subscription, Task, UpdateGlobal, WeakEntity, Window, actions, div,
26};
27use itertools::Itertools;
28use language::{Buffer, Language};
29use menu::Confirm;
30use multi_buffer;
31use project::{
32 Project, ProjectPath, SearchResults,
33 search::{SearchInputKind, SearchQuery},
34 search_history::SearchHistoryCursor,
35};
36use settings::Settings;
37use std::{
38 any::{Any, TypeId},
39 mem,
40 ops::{Not, Range},
41 pin::pin,
42 sync::Arc,
43};
44use ui::{
45 CommonAnimationExt, IconButtonShape, KeyBinding, Toggleable, Tooltip, prelude::*,
46 utils::SearchInputWidth,
47};
48use util::{ResultExt as _, paths::PathMatcher, rel_path::RelPath};
49use workspace::{
50 DeploySearch, ItemNavHistory, NewSearch, ToolbarItemEvent, ToolbarItemLocation,
51 ToolbarItemView, Workspace, WorkspaceId,
52 item::{Item, ItemEvent, ItemHandle, SaveOptions},
53 searchable::{Direction, SearchEvent, SearchToken, SearchableItem, SearchableItemHandle},
54};
55
56actions!(
57 project_search,
58 [
59 /// Searches in a new project search tab.
60 SearchInNew,
61 /// Toggles focus between the search bar and the search results.
62 ToggleFocus,
63 /// Moves to the next input field.
64 NextField,
65 /// Toggles the search filters panel.
66 ToggleFilters,
67 /// Toggles collapse/expand state of all search result excerpts.
68 ToggleAllSearchResults
69 ]
70);
71
72fn split_glob_patterns(text: &str) -> Vec<&str> {
73 let mut patterns = Vec::new();
74 let mut pattern_start = 0;
75 let mut brace_depth: usize = 0;
76 let mut escaped = false;
77
78 for (index, character) in text.char_indices() {
79 if escaped {
80 escaped = false;
81 continue;
82 }
83 match character {
84 '\\' => escaped = true,
85 '{' => brace_depth += 1,
86 '}' => brace_depth = brace_depth.saturating_sub(1),
87 ',' if brace_depth == 0 => {
88 patterns.push(&text[pattern_start..index]);
89 pattern_start = index + 1;
90 }
91 _ => {}
92 }
93 }
94 patterns.push(&text[pattern_start..]);
95 patterns
96}
97
98#[derive(Default)]
99struct ActiveSettings(HashMap<WeakEntity<Project>, ProjectSearchSettings>);
100
101impl Global for ActiveSettings {}
102
103pub fn init(cx: &mut App) {
104 cx.set_global(ActiveSettings::default());
105 cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
106 register_workspace_action(workspace, move |search_bar, _: &Deploy, window, cx| {
107 search_bar.focus_search(window, cx);
108 });
109 register_workspace_action(workspace, move |search_bar, _: &FocusSearch, window, cx| {
110 search_bar.focus_search(window, cx);
111 });
112 register_workspace_action(
113 workspace,
114 move |search_bar, _: &ToggleFilters, window, cx| {
115 search_bar.toggle_filters(window, cx);
116 },
117 );
118 register_workspace_action(
119 workspace,
120 move |search_bar, _: &ToggleCaseSensitive, window, cx| {
121 search_bar.toggle_search_option(SearchOptions::CASE_SENSITIVE, window, cx);
122 },
123 );
124 register_workspace_action(
125 workspace,
126 move |search_bar, _: &ToggleWholeWord, window, cx| {
127 search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx);
128 },
129 );
130 register_workspace_action(workspace, move |search_bar, _: &ToggleRegex, window, cx| {
131 search_bar.toggle_search_option(SearchOptions::REGEX, window, cx);
132 });
133 register_workspace_action(
134 workspace,
135 move |search_bar, action: &ToggleReplace, window, cx| {
136 search_bar.toggle_replace(action, window, cx)
137 },
138 );
139 register_workspace_action(
140 workspace,
141 move |search_bar, action: &SelectPreviousMatch, window, cx| {
142 search_bar.select_prev_match(action, window, cx)
143 },
144 );
145 register_workspace_action(
146 workspace,
147 move |search_bar, action: &SelectNextMatch, window, cx| {
148 search_bar.select_next_match(action, window, cx)
149 },
150 );
151
152 // Only handle search_in_new if there is a search present
153 register_workspace_action_for_present_search(workspace, |workspace, action, window, cx| {
154 ProjectSearchView::search_in_new(workspace, action, window, cx)
155 });
156
157 register_workspace_action_for_present_search(
158 workspace,
159 |workspace, action: &ToggleAllSearchResults, window, cx| {
160 if let Some(search_view) = workspace
161 .active_item(cx)
162 .and_then(|item| item.downcast::<ProjectSearchView>())
163 {
164 search_view.update(cx, |search_view, cx| {
165 search_view.toggle_all_search_results(action, window, cx);
166 });
167 }
168 },
169 );
170
171 register_workspace_action_for_present_search(
172 workspace,
173 |workspace, _: &menu::Cancel, window, cx| {
174 if let Some(project_search_bar) = workspace
175 .active_pane()
176 .read(cx)
177 .toolbar()
178 .read(cx)
179 .item_of_type::<ProjectSearchBar>()
180 {
181 project_search_bar.update(cx, |project_search_bar, cx| {
182 let search_is_focused = project_search_bar
183 .active_project_search
184 .as_ref()
185 .is_some_and(|search_view| {
186 search_view
187 .read(cx)
188 .query_editor
189 .read(cx)
190 .focus_handle(cx)
191 .is_focused(window)
192 });
193 if search_is_focused {
194 project_search_bar.move_focus_to_results(window, cx);
195 } else {
196 project_search_bar.focus_search(window, cx)
197 }
198 });
199 } else {
200 cx.propagate();
201 }
202 },
203 );
204
205 // Both on present and dismissed search, we need to unconditionally handle those actions to focus from the editor.
206 workspace.register_action(move |workspace, action: &DeploySearch, window, cx| {
207 if workspace.has_active_modal(window, cx) && !workspace.hide_modal(window, cx) {
208 cx.propagate();
209 return;
210 }
211 ProjectSearchView::deploy_search(workspace, action, window, cx);
212 cx.notify();
213 });
214 workspace.register_action(move |workspace, action: &NewSearch, window, cx| {
215 if workspace.has_active_modal(window, cx) && !workspace.hide_modal(window, cx) {
216 cx.propagate();
217 return;
218 }
219 ProjectSearchView::new_search(workspace, action, window, cx);
220 cx.notify();
221 });
222 })
223 .detach();
224}
225
226fn contains_uppercase(str: &str) -> bool {
227 str.chars().any(|c| c.is_uppercase())
228}
229
230pub struct ProjectSearch {
231 project: Entity<Project>,
232 excerpts: Entity<MultiBuffer>,
233 pending_search: Option<Task<Option<()>>>,
234 match_ranges: Vec<Range<Anchor>>,
235 active_query: Option<SearchQuery>,
236 last_search_query_text: Option<String>,
237 search_id: usize,
238 no_results: Option<bool>,
239 limit_reached: bool,
240 search_input_confirmed: bool,
241 search_history_cursor: SearchHistoryCursor,
242 search_included_history_cursor: SearchHistoryCursor,
243 search_excluded_history_cursor: SearchHistoryCursor,
244 _excerpts_subscription: Subscription,
245}
246
247#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
248enum InputPanel {
249 Query,
250 Replacement,
251 Exclude,
252 Include,
253}
254
255pub struct ProjectSearchView {
256 workspace: WeakEntity<Workspace>,
257 focus_handle: FocusHandle,
258 entity: Entity<ProjectSearch>,
259 query_editor: Entity<Editor>,
260 replacement_editor: Entity<Editor>,
261 results_editor: Entity<Editor>,
262 search_options: SearchOptions,
263 panels_with_errors: HashMap<InputPanel, String>,
264 active_match_index: Option<usize>,
265 search_id: usize,
266 included_files_editor: Entity<Editor>,
267 excluded_files_editor: Entity<Editor>,
268 filters_enabled: bool,
269 replace_enabled: bool,
270 pending_replace_all: bool,
271 included_opened_only: bool,
272 regex_language: Option<Arc<Language>>,
273 _subscriptions: Vec<Subscription>,
274}
275
276#[derive(Debug, Clone)]
277pub struct ProjectSearchSettings {
278 search_options: SearchOptions,
279 filters_enabled: bool,
280}
281
282pub struct ProjectSearchBar {
283 active_project_search: Option<Entity<ProjectSearchView>>,
284 subscription: Option<Subscription>,
285}
286
287impl ProjectSearch {
288 pub fn new(project: Entity<Project>, cx: &mut Context<Self>) -> Self {
289 let capability = project.read(cx).capability();
290 let excerpts = cx.new(|_| MultiBuffer::new(capability));
291 let subscription = Self::subscribe_to_excerpts(&excerpts, cx);
292
293 Self {
294 project,
295 excerpts,
296 pending_search: Default::default(),
297 match_ranges: Default::default(),
298 active_query: None,
299 last_search_query_text: None,
300 search_id: 0,
301 no_results: None,
302 limit_reached: false,
303 search_input_confirmed: false,
304 search_history_cursor: Default::default(),
305 search_included_history_cursor: Default::default(),
306 search_excluded_history_cursor: Default::default(),
307 _excerpts_subscription: subscription,
308 }
309 }
310
311 fn clone(&self, cx: &mut Context<Self>) -> Entity<Self> {
312 cx.new(|cx| {
313 let excerpts = self
314 .excerpts
315 .update(cx, |excerpts, cx| cx.new(|cx| excerpts.clone(cx)));
316 let subscription = Self::subscribe_to_excerpts(&excerpts, cx);
317
318 Self {
319 project: self.project.clone(),
320 excerpts,
321 pending_search: Default::default(),
322 match_ranges: self.match_ranges.clone(),
323 active_query: self.active_query.clone(),
324 last_search_query_text: self.last_search_query_text.clone(),
325 search_id: self.search_id,
326 no_results: self.no_results,
327 limit_reached: self.limit_reached,
328 search_input_confirmed: self.search_input_confirmed,
329 search_history_cursor: self.search_history_cursor.clone(),
330 search_included_history_cursor: self.search_included_history_cursor.clone(),
331 search_excluded_history_cursor: self.search_excluded_history_cursor.clone(),
332 _excerpts_subscription: subscription,
333 }
334 })
335 }
336
337 fn subscribe_to_excerpts(
338 excerpts: &Entity<MultiBuffer>,
339 cx: &mut Context<Self>,
340 ) -> Subscription {
341 cx.subscribe(excerpts, |this, _, event, cx| {
342 if matches!(event, multi_buffer::Event::FileHandleChanged) {
343 this.remove_deleted_buffers(cx);
344 }
345 })
346 }
347
348 fn remove_deleted_buffers(&mut self, cx: &mut Context<Self>) {
349 let deleted_buffer_ids = self
350 .excerpts
351 .read(cx)
352 .all_buffers_iter()
353 .filter(|buffer| {
354 buffer
355 .read(cx)
356 .file()
357 .is_some_and(|file| file.disk_state().is_deleted())
358 })
359 .map(|buffer| buffer.read(cx).remote_id())
360 .collect::<Vec<_>>();
361
362 if deleted_buffer_ids.is_empty() {
363 return;
364 }
365
366 let snapshot = self.excerpts.update(cx, |excerpts, cx| {
367 for buffer_id in deleted_buffer_ids {
368 excerpts.remove_excerpts_for_buffer(buffer_id, cx);
369 }
370 excerpts.snapshot(cx)
371 });
372
373 self.match_ranges
374 .retain(|range| snapshot.anchor_to_buffer_anchor(range.start).is_some());
375
376 cx.notify();
377 }
378
379 fn cursor(&self, kind: SearchInputKind) -> &SearchHistoryCursor {
380 match kind {
381 SearchInputKind::Query => &self.search_history_cursor,
382 SearchInputKind::Include => &self.search_included_history_cursor,
383 SearchInputKind::Exclude => &self.search_excluded_history_cursor,
384 }
385 }
386 fn cursor_mut(&mut self, kind: SearchInputKind) -> &mut SearchHistoryCursor {
387 match kind {
388 SearchInputKind::Query => &mut self.search_history_cursor,
389 SearchInputKind::Include => &mut self.search_included_history_cursor,
390 SearchInputKind::Exclude => &mut self.search_excluded_history_cursor,
391 }
392 }
393
394 fn search(&mut self, query: SearchQuery, incremental: bool, cx: &mut Context<Self>) {
395 let search = self.project.update(cx, |project, cx| {
396 project
397 .search_history_mut(SearchInputKind::Query)
398 .add(&mut self.search_history_cursor, query.as_str().to_string());
399 let included = query.as_inner().files_to_include().sources().join(",");
400 if !included.is_empty() {
401 project
402 .search_history_mut(SearchInputKind::Include)
403 .add(&mut self.search_included_history_cursor, included);
404 }
405 let excluded = query.as_inner().files_to_exclude().sources().join(",");
406 if !excluded.is_empty() {
407 project
408 .search_history_mut(SearchInputKind::Exclude)
409 .add(&mut self.search_excluded_history_cursor, excluded);
410 }
411 project.search(query.clone(), cx)
412 });
413 self.last_search_query_text = Some(query.as_str().to_string());
414 self.search_id += 1;
415 self.active_query = Some(query);
416 self.match_ranges.clear();
417 self.search_input_confirmed = !incremental;
418 self.pending_search = Some(cx.spawn(async move |project_search, cx| {
419 let SearchResults { rx, _task_handle } = search;
420
421 let mut matches = pin!(rx.ready_chunks(1024));
422
423 if !incremental {
424 project_search
425 .update(cx, |project_search, cx| {
426 project_search.match_ranges.clear();
427 project_search
428 .excerpts
429 .update(cx, |excerpts, cx| excerpts.clear(cx));
430 project_search.no_results = Some(true);
431 project_search.limit_reached = false;
432 })
433 .ok()?;
434 }
435
436 let mut limit_reached = false;
437 let mut seen_paths = HashSet::default();
438 while let Some(results) = matches.next().await {
439 let (buffers_with_ranges, has_reached_limit) = cx
440 .background_executor()
441 .spawn(async move {
442 let mut limit_reached = false;
443 let mut buffers_with_ranges = Vec::with_capacity(results.len());
444 for result in results {
445 match result {
446 project::search::SearchResult::Buffer { buffer, ranges } => {
447 buffers_with_ranges.push((buffer, ranges));
448 }
449 project::search::SearchResult::LimitReached => {
450 limit_reached = true;
451 }
452 }
453 }
454 (buffers_with_ranges, limit_reached)
455 })
456 .await;
457 limit_reached |= has_reached_limit;
458
459 if incremental {
460 let buffers_with_ranges = buffers_with_ranges
461 .into_iter()
462 .filter(|(_, ranges)| !ranges.is_empty())
463 .collect::<Vec<_>>();
464 if buffers_with_ranges.is_empty() {
465 continue;
466 }
467 let (mut chunk_ranges, chunk_paths) = project_search
468 .update(cx, |project_search, cx| {
469 let mut paths = Vec::new();
470 let futures = project_search.excerpts.update(cx, |excerpts, cx| {
471 buffers_with_ranges
472 .into_iter()
473 .map(|(buffer, ranges)| {
474 let path_key = PathKey::for_buffer(&buffer, cx);
475 paths.push(path_key.clone());
476 excerpts.set_anchored_excerpts_for_path(
477 path_key,
478 buffer,
479 ranges,
480 multibuffer_context_lines(cx),
481 cx,
482 )
483 })
484 .collect::<FuturesOrdered<_>>()
485 });
486 (futures, paths)
487 })
488 .ok()?;
489 seen_paths.extend(chunk_paths);
490 while let Some(ranges) = chunk_ranges.next().await {
491 smol::future::yield_now().await;
492 project_search
493 .update(cx, |project_search, cx| {
494 project_search.match_ranges.extend(ranges);
495 cx.notify();
496 })
497 .ok()?;
498 }
499 project_search
500 .update(cx, |project_search, cx| {
501 project_search.excerpts.update(cx, |excerpts, cx| {
502 for path in excerpts
503 .existing_excerpts()
504 .into_iter()
505 .filter(|path| !seen_paths.contains(path))
506 {
507 excerpts.remove_excerpts(path, cx);
508 }
509 });
510 })
511 .ok()?;
512 continue;
513 }
514
515 let mut new_ranges = project_search
516 .update(cx, |project_search, cx| {
517 project_search.excerpts.update(cx, |excerpts, cx| {
518 buffers_with_ranges
519 .into_iter()
520 .map(|(buffer, ranges)| {
521 excerpts.set_anchored_excerpts_for_path(
522 PathKey::for_buffer(&buffer, cx),
523 buffer,
524 ranges,
525 multibuffer_context_lines(cx),
526 cx,
527 )
528 })
529 .collect::<FuturesOrdered<_>>()
530 })
531 })
532 .ok()?;
533 while let Some(new_ranges) = new_ranges.next().await {
534 // `new_ranges.next().await` likely never gets hit while still pending so `async_task`
535 // will not reschedule, starving other front end tasks, insert a yield point for that here
536 smol::future::yield_now().await;
537 project_search
538 .update(cx, |project_search, cx| {
539 project_search.match_ranges.extend(new_ranges);
540 cx.notify();
541 })
542 .ok()?;
543 }
544 }
545
546 project_search
547 .update(cx, |project_search, cx| {
548 project_search.no_results = Some(project_search.match_ranges.is_empty());
549 project_search.limit_reached = limit_reached;
550 project_search.pending_search.take();
551 if incremental && seen_paths.is_empty() {
552 project_search
553 .excerpts
554 .update(cx, |excerpts, cx| excerpts.clear(cx));
555 }
556 cx.notify();
557 })
558 .ok()?;
559
560 None
561 }));
562 cx.notify();
563 }
564}
565
566#[derive(Clone, Debug, PartialEq, Eq)]
567pub enum ViewEvent {
568 UpdateTab,
569 Activate,
570 EditorEvent(editor::EditorEvent),
571 Dismiss,
572}
573
574impl EventEmitter<ViewEvent> for ProjectSearchView {}
575
576impl Render for ProjectSearchView {
577 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
578 if self.has_matches() {
579 div()
580 .flex_1()
581 .size_full()
582 .track_focus(&self.focus_handle(cx))
583 .child(self.results_editor.clone())
584 } else {
585 let model = self.entity.read(cx);
586 let has_no_results = model.no_results.unwrap_or(false);
587 let is_search_underway = model.pending_search.is_some();
588
589 let heading_text = if is_search_underway {
590 "Searching…"
591 } else if has_no_results {
592 "No Results"
593 } else {
594 "Search All Files"
595 };
596
597 let heading_text = div()
598 .justify_center()
599 .child(Label::new(heading_text).size(LabelSize::Large));
600
601 let page_content: Option<AnyElement> = if let Some(no_results) = model.no_results {
602 if model.pending_search.is_none() && no_results {
603 Some(
604 Label::new("No results found in this project for the provided query")
605 .size(LabelSize::Small)
606 .into_any_element(),
607 )
608 } else {
609 None
610 }
611 } else {
612 Some(self.landing_text_minor(cx).into_any_element())
613 };
614
615 let page_content = page_content.map(|text| div().child(text));
616
617 h_flex()
618 .size_full()
619 .items_center()
620 .justify_center()
621 .overflow_hidden()
622 .bg(cx.theme().colors().editor_background)
623 .track_focus(&self.focus_handle(cx))
624 .child(
625 v_flex()
626 .id("project-search-landing-page")
627 .overflow_y_scroll()
628 .gap_1()
629 .child(heading_text)
630 .children(page_content),
631 )
632 }
633 }
634}
635
636impl Focusable for ProjectSearchView {
637 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
638 self.focus_handle.clone()
639 }
640}
641
642impl Item for ProjectSearchView {
643 type Event = ViewEvent;
644 fn tab_tooltip_text(&self, cx: &App) -> Option<SharedString> {
645 let query_text = self.query_editor.read(cx).text(cx);
646
647 query_text
648 .is_empty()
649 .not()
650 .then(|| query_text.into())
651 .or_else(|| Some("Project Search".into()))
652 }
653
654 fn act_as_type<'a>(
655 &'a self,
656 type_id: TypeId,
657 self_handle: &'a Entity<Self>,
658 _: &'a App,
659 ) -> Option<gpui::AnyEntity> {
660 if type_id == TypeId::of::<Self>() {
661 Some(self_handle.clone().into())
662 } else if type_id == TypeId::of::<Editor>() {
663 Some(self.results_editor.clone().into())
664 } else {
665 None
666 }
667 }
668 fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
669 Some(Box::new(self.results_editor.clone()))
670 }
671
672 fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
673 self.results_editor
674 .update(cx, |editor, cx| editor.deactivated(window, cx));
675 }
676
677 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
678 Some(Icon::new(IconName::MagnifyingGlass))
679 }
680
681 fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
682 let last_query: Option<SharedString> = self
683 .entity
684 .read(cx)
685 .last_search_query_text
686 .as_ref()
687 .map(|query| {
688 let query = query.replace('\n', "");
689 let query_text = util::truncate_and_trailoff(&query, MAX_TAB_TITLE_LEN);
690 query_text.into()
691 });
692
693 last_query
694 .filter(|query| !query.is_empty())
695 .unwrap_or_else(|| "Project Search".into())
696 }
697
698 fn telemetry_event_text(&self) -> Option<&'static str> {
699 Some("Project Search Opened")
700 }
701
702 fn for_each_project_item(
703 &self,
704 cx: &App,
705 f: &mut dyn FnMut(EntityId, &dyn project::ProjectItem),
706 ) {
707 self.results_editor.for_each_project_item(cx, f)
708 }
709
710 fn can_save(&self, _: &App) -> bool {
711 true
712 }
713
714 fn is_dirty(&self, cx: &App) -> bool {
715 self.results_editor.read(cx).is_dirty(cx)
716 }
717
718 fn has_conflict(&self, cx: &App) -> bool {
719 self.results_editor.read(cx).has_conflict(cx)
720 }
721
722 fn save(
723 &mut self,
724 options: SaveOptions,
725 project: Entity<Project>,
726 window: &mut Window,
727 cx: &mut Context<Self>,
728 ) -> Task<anyhow::Result<()>> {
729 self.results_editor
730 .update(cx, |editor, cx| editor.save(options, project, window, cx))
731 }
732
733 fn save_as(
734 &mut self,
735 _: Entity<Project>,
736 _: ProjectPath,
737 _window: &mut Window,
738 _: &mut Context<Self>,
739 ) -> Task<anyhow::Result<()>> {
740 unreachable!("save_as should not have been called")
741 }
742
743 fn reload(
744 &mut self,
745 project: Entity<Project>,
746 window: &mut Window,
747 cx: &mut Context<Self>,
748 ) -> Task<anyhow::Result<()>> {
749 self.results_editor
750 .update(cx, |editor, cx| editor.reload(project, window, cx))
751 }
752
753 fn can_split(&self) -> bool {
754 true
755 }
756
757 fn clone_on_split(
758 &self,
759 _workspace_id: Option<WorkspaceId>,
760 window: &mut Window,
761 cx: &mut Context<Self>,
762 ) -> Task<Option<Entity<Self>>>
763 where
764 Self: Sized,
765 {
766 let model = self.entity.update(cx, |model, cx| model.clone(cx));
767 Task::ready(Some(cx.new(|cx| {
768 Self::new(self.workspace.clone(), model, window, cx, None)
769 })))
770 }
771
772 fn added_to_workspace(
773 &mut self,
774 workspace: &mut Workspace,
775 window: &mut Window,
776 cx: &mut Context<Self>,
777 ) {
778 self.results_editor.update(cx, |editor, cx| {
779 editor.added_to_workspace(workspace, window, cx)
780 });
781 }
782
783 fn set_nav_history(
784 &mut self,
785 nav_history: ItemNavHistory,
786 _: &mut Window,
787 cx: &mut Context<Self>,
788 ) {
789 self.results_editor.update(cx, |editor, _| {
790 editor.set_nav_history(Some(nav_history));
791 });
792 }
793
794 fn navigate(
795 &mut self,
796 data: Arc<dyn Any + Send>,
797 window: &mut Window,
798 cx: &mut Context<Self>,
799 ) -> bool {
800 self.results_editor
801 .update(cx, |editor, cx| editor.navigate(data, window, cx))
802 }
803
804 fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(ItemEvent)) {
805 match event {
806 ViewEvent::UpdateTab => {
807 f(ItemEvent::UpdateBreadcrumbs);
808 f(ItemEvent::UpdateTab);
809 }
810 ViewEvent::EditorEvent(editor_event) => {
811 Editor::to_item_events(editor_event, f);
812 }
813 ViewEvent::Dismiss => f(ItemEvent::CloseItem),
814 _ => {}
815 }
816 }
817}
818
819impl ProjectSearchView {
820 pub fn get_matches(&self, cx: &App) -> Vec<Range<Anchor>> {
821 self.entity.read(cx).match_ranges.clone()
822 }
823
824 fn toggle_filters(&mut self, cx: &mut Context<Self>) {
825 self.filters_enabled = !self.filters_enabled;
826 ActiveSettings::update_global(cx, |settings, cx| {
827 settings.0.insert(
828 self.entity.read(cx).project.downgrade(),
829 self.current_settings(),
830 );
831 });
832 }
833
834 fn current_settings(&self) -> ProjectSearchSettings {
835 ProjectSearchSettings {
836 search_options: self.search_options,
837 filters_enabled: self.filters_enabled,
838 }
839 }
840
841 fn set_search_option_enabled(
842 &mut self,
843 option: SearchOptions,
844 enabled: bool,
845 cx: &mut Context<Self>,
846 ) {
847 if self.search_options.contains(option) != enabled {
848 self.toggle_search_option(option, cx);
849 }
850 }
851
852 fn toggle_search_option(&mut self, option: SearchOptions, cx: &mut Context<Self>) {
853 self.search_options.toggle(option);
854 ActiveSettings::update_global(cx, |settings, cx| {
855 settings.0.insert(
856 self.entity.read(cx).project.downgrade(),
857 self.current_settings(),
858 );
859 });
860 self.adjust_query_regex_language(cx);
861 }
862
863 fn toggle_opened_only(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {
864 self.included_opened_only = !self.included_opened_only;
865 }
866
867 pub fn replacement(&self, cx: &App) -> String {
868 self.replacement_editor.read(cx).text(cx)
869 }
870
871 fn replace_next(&mut self, _: &ReplaceNext, window: &mut Window, cx: &mut Context<Self>) {
872 if self.entity.read(cx).pending_search.is_some() {
873 return;
874 }
875 if let Some(last_search_query_text) = &self.entity.read(cx).last_search_query_text
876 && self.query_editor.read(cx).text(cx) != *last_search_query_text
877 {
878 // search query has changed, restart search and bail
879 self.search(false, cx);
880 return;
881 }
882 if self.entity.read(cx).match_ranges.is_empty() {
883 return;
884 }
885 let Some(active_index) = self.active_match_index else {
886 return;
887 };
888
889 let query = self.entity.read(cx).active_query.clone();
890 if let Some(query) = query {
891 let query = query.with_replacement(self.replacement(cx));
892
893 let mat = self.entity.read(cx).match_ranges.get(active_index).cloned();
894 self.results_editor.update(cx, |editor, cx| {
895 if let Some(mat) = mat.as_ref() {
896 editor.replace(mat, &query, SearchToken::default(), window, cx);
897 }
898 });
899 self.select_match(Direction::Next, window, cx)
900 }
901 }
902
903 fn replace_all(&mut self, _: &ReplaceAll, window: &mut Window, cx: &mut Context<Self>) {
904 if self.entity.read(cx).pending_search.is_some() {
905 self.pending_replace_all = true;
906 return;
907 }
908 let query_text = self.query_editor.read(cx).text(cx);
909 let query_is_stale =
910 self.entity.read(cx).last_search_query_text.as_deref() != Some(query_text.as_str());
911 if query_is_stale {
912 self.pending_replace_all = true;
913 self.search(false, cx);
914 if self.entity.read(cx).pending_search.is_none() {
915 self.pending_replace_all = false;
916 }
917 return;
918 }
919 self.pending_replace_all = false;
920 if self.active_match_index.is_none() {
921 return;
922 }
923 let Some(query) = self.entity.read(cx).active_query.as_ref() else {
924 return;
925 };
926 let query = query.clone().with_replacement(self.replacement(cx));
927
928 let match_ranges = self
929 .entity
930 .update(cx, |model, _| mem::take(&mut model.match_ranges));
931 if match_ranges.is_empty() {
932 return;
933 }
934
935 self.results_editor.update(cx, |editor, cx| {
936 editor.replace_all(
937 &mut match_ranges.iter(),
938 &query,
939 SearchToken::default(),
940 window,
941 cx,
942 );
943 });
944
945 self.entity.update(cx, |model, _cx| {
946 model.match_ranges = match_ranges;
947 });
948 }
949
950 fn toggle_all_search_results(
951 &mut self,
952 _: &ToggleAllSearchResults,
953 window: &mut Window,
954 cx: &mut Context<Self>,
955 ) {
956 self.update_results_visibility(window, cx);
957 }
958
959 fn update_results_visibility(&mut self, window: &mut Window, cx: &mut Context<Self>) {
960 let has_any_folded = self.results_editor.read(cx).has_any_buffer_folded(cx);
961 self.results_editor.update(cx, |editor, cx| {
962 if has_any_folded {
963 editor.unfold_all(&UnfoldAll, window, cx);
964 } else {
965 editor.fold_all(&FoldAll, window, cx);
966 }
967 });
968 cx.notify();
969 }
970
971 pub fn new(
972 workspace: WeakEntity<Workspace>,
973 entity: Entity<ProjectSearch>,
974 window: &mut Window,
975 cx: &mut Context<Self>,
976 settings: Option<ProjectSearchSettings>,
977 ) -> Self {
978 let project;
979 let excerpts;
980 let mut replacement_text = None;
981 let mut query_text = String::new();
982 let mut subscriptions = Vec::new();
983
984 // Read in settings if available
985 let (mut options, filters_enabled) = if let Some(settings) = settings {
986 (settings.search_options, settings.filters_enabled)
987 } else {
988 let search_options =
989 SearchOptions::from_settings(&EditorSettings::get_global(cx).search);
990 (search_options, false)
991 };
992
993 {
994 let entity = entity.read(cx);
995 project = entity.project.clone();
996 excerpts = entity.excerpts.clone();
997 if let Some(active_query) = entity.active_query.as_ref() {
998 query_text = active_query.as_str().to_string();
999 replacement_text = active_query.replacement().map(ToOwned::to_owned);
1000 options = SearchOptions::from_query(active_query);
1001 }
1002 }
1003 subscriptions.push(cx.observe_in(&entity, window, |this, _, window, cx| {
1004 this.entity_changed(window, cx)
1005 }));
1006
1007 let query_editor = cx.new(|cx| {
1008 let mut editor = Editor::auto_height(1, 4, window, cx);
1009 editor.set_placeholder_text("Search all files…", window, cx);
1010 editor.set_use_autoclose(false);
1011 editor.set_use_selection_highlight(false);
1012 editor.set_text(query_text, window, cx);
1013 editor
1014 });
1015 // Subscribe to query_editor in order to reraise editor events for workspace item activation purposes
1016 subscriptions.push(
1017 cx.subscribe(&query_editor, |this, _, event: &EditorEvent, cx| {
1018 if let EditorEvent::Edited { .. } = event {
1019 if EditorSettings::get_global(cx).use_smartcase_search {
1020 let query = this.search_query_text(cx);
1021 if !query.is_empty()
1022 && this.search_options.contains(SearchOptions::CASE_SENSITIVE)
1023 != contains_uppercase(&query)
1024 {
1025 this.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
1026 }
1027 }
1028
1029 if EditorSettings::get_global(cx).search.search_on_input {
1030 if this.query_editor.read(cx).is_empty(cx) {
1031 this.entity.update(cx, |model, cx| {
1032 model.pending_search = None;
1033 model.match_ranges.clear();
1034 model.excerpts.update(cx, |excerpts, cx| excerpts.clear(cx));
1035 model.no_results = None;
1036 model.limit_reached = false;
1037 model.last_search_query_text = None;
1038 cx.notify();
1039 });
1040 } else {
1041 this.search(true, cx);
1042 }
1043 }
1044 }
1045 cx.emit(ViewEvent::EditorEvent(event.clone()))
1046 }),
1047 );
1048 let replacement_editor = cx.new(|cx| {
1049 let mut editor = Editor::auto_height(1, 4, window, cx);
1050 editor.set_placeholder_text("Replace in project…", window, cx);
1051 if let Some(text) = replacement_text {
1052 editor.set_text(text, window, cx);
1053 }
1054 editor
1055 });
1056 let results_editor = cx.new(|cx| {
1057 let mut editor = Editor::for_multibuffer(excerpts, Some(project.clone()), window, cx);
1058 editor.set_searchable(false);
1059 editor.set_in_project_search(true);
1060 editor
1061 });
1062 subscriptions.push(cx.observe(&results_editor, |_, _, cx| cx.emit(ViewEvent::UpdateTab)));
1063
1064 subscriptions.push(
1065 cx.subscribe(&results_editor, |this, _, event: &EditorEvent, cx| {
1066 if matches!(event, editor::EditorEvent::SelectionsChanged { .. }) {
1067 this.update_match_index(cx);
1068 }
1069 // Reraise editor events for workspace item activation purposes
1070 cx.emit(ViewEvent::EditorEvent(event.clone()));
1071 }),
1072 );
1073 subscriptions.push(cx.subscribe(
1074 &results_editor,
1075 |_this, _editor, _event: &SearchEvent, cx| cx.notify(),
1076 ));
1077
1078 let included_files_editor = cx.new(|cx| {
1079 let mut editor = Editor::single_line(window, cx);
1080 editor.set_placeholder_text("Include: crates/**/*.toml", window, cx);
1081
1082 editor
1083 });
1084 // Subscribe to include_files_editor in order to reraise editor events for workspace item activation purposes
1085 subscriptions.push(
1086 cx.subscribe(&included_files_editor, |_, _, event: &EditorEvent, cx| {
1087 cx.emit(ViewEvent::EditorEvent(event.clone()))
1088 }),
1089 );
1090
1091 let excluded_files_editor = cx.new(|cx| {
1092 let mut editor = Editor::single_line(window, cx);
1093 editor.set_placeholder_text("Exclude: vendor/*, *.lock", window, cx);
1094
1095 editor
1096 });
1097 // Subscribe to excluded_files_editor in order to reraise editor events for workspace item activation purposes
1098 subscriptions.push(
1099 cx.subscribe(&excluded_files_editor, |_, _, event: &EditorEvent, cx| {
1100 cx.emit(ViewEvent::EditorEvent(event.clone()))
1101 }),
1102 );
1103
1104 let focus_handle = cx.focus_handle();
1105 subscriptions.push(cx.on_focus(&focus_handle, window, |_, window, cx| {
1106 cx.on_next_frame(window, |this, window, cx| {
1107 if this.focus_handle.is_focused(window) {
1108 if this.has_matches() {
1109 this.results_editor.focus_handle(cx).focus(window, cx);
1110 } else {
1111 this.query_editor.focus_handle(cx).focus(window, cx);
1112 }
1113 }
1114 });
1115 }));
1116
1117 let languages = project.read(cx).languages().clone();
1118 cx.spawn(async move |project_search_view, cx| {
1119 let regex_language = languages
1120 .language_for_name("regex")
1121 .await
1122 .context("loading regex language")?;
1123 project_search_view
1124 .update(cx, |project_search_view, cx| {
1125 project_search_view.regex_language = Some(regex_language);
1126 project_search_view.adjust_query_regex_language(cx);
1127 })
1128 .ok();
1129 anyhow::Ok(())
1130 })
1131 .detach_and_log_err(cx);
1132
1133 // Check if Worktrees have all been previously indexed
1134 let mut this = ProjectSearchView {
1135 workspace,
1136 focus_handle,
1137 replacement_editor,
1138 search_id: entity.read(cx).search_id,
1139 entity,
1140 query_editor,
1141 results_editor,
1142 search_options: options,
1143 panels_with_errors: HashMap::default(),
1144 active_match_index: None,
1145 included_files_editor,
1146 excluded_files_editor,
1147 filters_enabled,
1148 replace_enabled: false,
1149 pending_replace_all: false,
1150 included_opened_only: false,
1151 regex_language: None,
1152 _subscriptions: subscriptions,
1153 };
1154
1155 this.entity_changed(window, cx);
1156 this
1157 }
1158
1159 pub fn new_search_in_directory(
1160 workspace: &mut Workspace,
1161 dir_path: &RelPath,
1162 window: &mut Window,
1163 cx: &mut Context<Workspace>,
1164 ) {
1165 let filter_str = dir_path.display(workspace.path_style(cx));
1166
1167 let weak_workspace = cx.entity().downgrade();
1168
1169 let entity = cx.new(|cx| ProjectSearch::new(workspace.project().clone(), cx));
1170 let search = cx.new(|cx| ProjectSearchView::new(weak_workspace, entity, window, cx, None));
1171 workspace.add_item_to_active_pane(Box::new(search.clone()), None, true, window, cx);
1172 search.update(cx, |search, cx| {
1173 search
1174 .included_files_editor
1175 .update(cx, |editor, cx| editor.set_text(filter_str, window, cx));
1176 search.filters_enabled = true;
1177 search.focus_query_editor(window, cx)
1178 });
1179 }
1180
1181 /// Re-activate the most recently activated search in this pane or the most recent if it has been closed.
1182 /// If no search exists in the workspace, create a new one.
1183 pub fn deploy_search(
1184 workspace: &mut Workspace,
1185 action: &workspace::DeploySearch,
1186 window: &mut Window,
1187 cx: &mut Context<Workspace>,
1188 ) {
1189 let existing = workspace
1190 .active_pane()
1191 .read(cx)
1192 .items()
1193 .find_map(|item| item.downcast::<ProjectSearchView>());
1194
1195 Self::existing_or_new_search(workspace, existing, action, window, cx);
1196 }
1197
1198 fn search_in_new(
1199 workspace: &mut Workspace,
1200 _: &SearchInNew,
1201 window: &mut Window,
1202 cx: &mut Context<Workspace>,
1203 ) {
1204 if let Some(search_view) = workspace
1205 .active_item(cx)
1206 .and_then(|item| item.downcast::<ProjectSearchView>())
1207 {
1208 let new_query = search_view.update(cx, |search_view, cx| {
1209 let open_buffers = if search_view.included_opened_only {
1210 Some(search_view.open_buffers(cx, workspace))
1211 } else {
1212 None
1213 };
1214 let new_query = search_view.build_search_query(cx, open_buffers);
1215 if new_query.is_some()
1216 && let Some(old_query) = search_view.entity.read(cx).active_query.clone()
1217 {
1218 search_view.query_editor.update(cx, |editor, cx| {
1219 editor.set_text(old_query.as_str(), window, cx);
1220 });
1221 search_view.search_options = SearchOptions::from_query(&old_query);
1222 search_view.adjust_query_regex_language(cx);
1223 }
1224 new_query
1225 });
1226 if let Some(new_query) = new_query {
1227 let entity = cx.new(|cx| {
1228 let mut entity = ProjectSearch::new(workspace.project().clone(), cx);
1229 entity.search(new_query, false, cx);
1230 entity
1231 });
1232 let weak_workspace = cx.entity().downgrade();
1233 workspace.add_item_to_active_pane(
1234 Box::new(cx.new(|cx| {
1235 ProjectSearchView::new(weak_workspace, entity, window, cx, None)
1236 })),
1237 None,
1238 true,
1239 window,
1240 cx,
1241 );
1242 }
1243 }
1244 }
1245
1246 // Add another search tab to the workspace.
1247 fn new_search(
1248 workspace: &mut Workspace,
1249 _: &workspace::NewSearch,
1250 window: &mut Window,
1251 cx: &mut Context<Workspace>,
1252 ) {
1253 Self::existing_or_new_search(workspace, None, &DeploySearch::default(), window, cx)
1254 }
1255
1256 fn existing_or_new_search(
1257 workspace: &mut Workspace,
1258 existing: Option<Entity<ProjectSearchView>>,
1259 action: &workspace::DeploySearch,
1260 window: &mut Window,
1261 cx: &mut Context<Workspace>,
1262 ) {
1263 let query = workspace.active_item(cx).and_then(|item| {
1264 if let Some(buffer_search_query) = buffer_search_query(workspace, item.as_ref(), cx) {
1265 return Some(buffer_search_query);
1266 }
1267
1268 let editor = item.act_as::<Editor>(cx)?;
1269 let query = editor.query_suggestion(window, cx);
1270 if query.is_empty() { None } else { Some(query) }
1271 });
1272
1273 let search = if let Some(existing) = existing {
1274 workspace.activate_item(&existing, true, true, window, cx);
1275 existing
1276 } else {
1277 let settings = cx
1278 .global::<ActiveSettings>()
1279 .0
1280 .get(&workspace.project().downgrade());
1281
1282 let settings = settings.cloned();
1283
1284 let weak_workspace = cx.entity().downgrade();
1285
1286 let project_search = cx.new(|cx| ProjectSearch::new(workspace.project().clone(), cx));
1287 let project_search_view = cx.new(|cx| {
1288 ProjectSearchView::new(weak_workspace, project_search, window, cx, settings)
1289 });
1290
1291 workspace.add_item_to_active_pane(
1292 Box::new(project_search_view.clone()),
1293 None,
1294 true,
1295 window,
1296 cx,
1297 );
1298 project_search_view
1299 };
1300
1301 search.update(cx, |search, cx| {
1302 search.replace_enabled |= action.replace_enabled;
1303 if let Some(regex) = action.regex {
1304 search.set_search_option_enabled(SearchOptions::REGEX, regex, cx);
1305 }
1306 if let Some(case_sensitive) = action.case_sensitive {
1307 search.set_search_option_enabled(SearchOptions::CASE_SENSITIVE, case_sensitive, cx);
1308 }
1309 if let Some(whole_word) = action.whole_word {
1310 search.set_search_option_enabled(SearchOptions::WHOLE_WORD, whole_word, cx);
1311 }
1312 if let Some(include_ignored) = action.include_ignored {
1313 search.set_search_option_enabled(
1314 SearchOptions::INCLUDE_IGNORED,
1315 include_ignored,
1316 cx,
1317 );
1318 }
1319 let query = action
1320 .query
1321 .as_deref()
1322 .filter(|q| !q.is_empty())
1323 .or(query.as_deref());
1324 if let Some(query) = query {
1325 search.set_query(query, window, cx);
1326 }
1327 if let Some(included_files) = action.included_files.as_deref() {
1328 search
1329 .included_files_editor
1330 .update(cx, |editor, cx| editor.set_text(included_files, window, cx));
1331 search.filters_enabled = true;
1332 }
1333 if let Some(excluded_files) = action.excluded_files.as_deref() {
1334 search
1335 .excluded_files_editor
1336 .update(cx, |editor, cx| editor.set_text(excluded_files, window, cx));
1337 search.filters_enabled = true;
1338 }
1339 search.focus_query_editor(window, cx)
1340 });
1341 }
1342
1343 fn prompt_to_save_if_dirty_then_search(
1344 &mut self,
1345 window: &mut Window,
1346 cx: &mut Context<Self>,
1347 ) -> Task<anyhow::Result<()>> {
1348 let project = self.entity.read(cx).project.clone();
1349
1350 let can_autosave = self.results_editor.can_autosave(cx);
1351 let autosave_setting = self.results_editor.workspace_settings(cx).autosave;
1352
1353 let will_autosave = can_autosave && autosave_setting.should_save_on_close();
1354
1355 let is_dirty = self.is_dirty(cx);
1356
1357 cx.spawn_in(window, async move |this, cx| {
1358 let skip_save_on_close = this
1359 .read_with(cx, |this, cx| {
1360 this.workspace.read_with(cx, |workspace, cx| {
1361 workspace::Pane::skip_save_on_close(&this.results_editor, workspace, cx)
1362 })
1363 })?
1364 .unwrap_or(false);
1365
1366 let should_prompt_to_save = !skip_save_on_close && !will_autosave && is_dirty;
1367
1368 let should_search = if should_prompt_to_save {
1369 let options = &["Save", "Don't Save", "Cancel"];
1370 let result_channel = this.update_in(cx, |_, window, cx| {
1371 window.prompt(
1372 gpui::PromptLevel::Warning,
1373 "Project search buffer contains unsaved edits. Do you want to save it?",
1374 None,
1375 options,
1376 cx,
1377 )
1378 })?;
1379 let result = result_channel.await?;
1380 let should_save = result == 0;
1381 if should_save {
1382 this.update_in(cx, |this, window, cx| {
1383 this.save(
1384 SaveOptions {
1385 format: true,
1386 autosave: false,
1387 },
1388 project,
1389 window,
1390 cx,
1391 )
1392 })?
1393 .await
1394 .log_err();
1395 }
1396
1397 result != 2
1398 } else {
1399 true
1400 };
1401 if should_search {
1402 this.update(cx, |this, cx| {
1403 this.search(false, cx);
1404 })?;
1405 }
1406 anyhow::Ok(())
1407 })
1408 }
1409
1410 fn search(&mut self, incremental: bool, cx: &mut Context<Self>) {
1411 let open_buffers = if self.included_opened_only {
1412 self.workspace
1413 .update(cx, |workspace, cx| self.open_buffers(cx, workspace))
1414 .ok()
1415 } else {
1416 None
1417 };
1418 if let Some(query) = self.build_search_query(cx, open_buffers) {
1419 self.entity
1420 .update(cx, |model, cx| model.search(query, incremental, cx));
1421 }
1422 }
1423
1424 pub fn search_query_text(&self, cx: &App) -> String {
1425 self.query_editor.read(cx).text(cx)
1426 }
1427
1428 fn build_search_query(
1429 &mut self,
1430 cx: &mut Context<Self>,
1431 open_buffers: Option<Vec<Entity<Buffer>>>,
1432 ) -> Option<SearchQuery> {
1433 // Do not bail early in this function, as we want to fill out `self.panels_with_errors`.
1434
1435 let text = self.search_query_text(cx);
1436 let included_files = self
1437 .filters_enabled
1438 .then(|| {
1439 match self.parse_path_matches(self.included_files_editor.read(cx).text(cx), cx) {
1440 Ok(included_files) => {
1441 let should_unmark_error =
1442 self.panels_with_errors.remove(&InputPanel::Include);
1443 if should_unmark_error.is_some() {
1444 cx.notify();
1445 }
1446 included_files
1447 }
1448 Err(e) => {
1449 let should_mark_error = self
1450 .panels_with_errors
1451 .insert(InputPanel::Include, e.to_string());
1452 if should_mark_error.is_none() {
1453 cx.notify();
1454 }
1455 PathMatcher::default()
1456 }
1457 }
1458 })
1459 .unwrap_or(PathMatcher::default());
1460 let excluded_files = self
1461 .filters_enabled
1462 .then(|| {
1463 match self.parse_path_matches(self.excluded_files_editor.read(cx).text(cx), cx) {
1464 Ok(excluded_files) => {
1465 let should_unmark_error =
1466 self.panels_with_errors.remove(&InputPanel::Exclude);
1467 if should_unmark_error.is_some() {
1468 cx.notify();
1469 }
1470
1471 excluded_files
1472 }
1473 Err(e) => {
1474 let should_mark_error = self
1475 .panels_with_errors
1476 .insert(InputPanel::Exclude, e.to_string());
1477 if should_mark_error.is_none() {
1478 cx.notify();
1479 }
1480 PathMatcher::default()
1481 }
1482 }
1483 })
1484 .unwrap_or(PathMatcher::default());
1485
1486 // If the project contains multiple visible worktrees, we match the
1487 // include/exclude patterns against full paths to allow them to be
1488 // disambiguated. For single worktree projects we use worktree relative
1489 // paths for convenience.
1490 let match_full_paths = self
1491 .entity
1492 .read(cx)
1493 .project
1494 .read(cx)
1495 .visible_worktrees(cx)
1496 .count()
1497 > 1;
1498
1499 let query = if self.search_options.contains(SearchOptions::REGEX) {
1500 match SearchQuery::regex(
1501 text,
1502 self.search_options.contains(SearchOptions::WHOLE_WORD),
1503 self.search_options.contains(SearchOptions::CASE_SENSITIVE),
1504 self.search_options.contains(SearchOptions::INCLUDE_IGNORED),
1505 self.search_options
1506 .contains(SearchOptions::ONE_MATCH_PER_LINE),
1507 included_files,
1508 excluded_files,
1509 match_full_paths,
1510 open_buffers,
1511 ) {
1512 Ok(query) => {
1513 let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Query);
1514 if should_unmark_error.is_some() {
1515 cx.notify();
1516 }
1517
1518 Some(query)
1519 }
1520 Err(e) => {
1521 let should_mark_error = self
1522 .panels_with_errors
1523 .insert(InputPanel::Query, e.to_string());
1524 if should_mark_error.is_none() {
1525 cx.notify();
1526 }
1527
1528 None
1529 }
1530 }
1531 } else {
1532 match SearchQuery::text(
1533 text,
1534 self.search_options.contains(SearchOptions::WHOLE_WORD),
1535 self.search_options.contains(SearchOptions::CASE_SENSITIVE),
1536 self.search_options.contains(SearchOptions::INCLUDE_IGNORED),
1537 included_files,
1538 excluded_files,
1539 match_full_paths,
1540 open_buffers,
1541 ) {
1542 Ok(query) => {
1543 let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Query);
1544 if should_unmark_error.is_some() {
1545 cx.notify();
1546 }
1547
1548 Some(query)
1549 }
1550 Err(e) => {
1551 let should_mark_error = self
1552 .panels_with_errors
1553 .insert(InputPanel::Query, e.to_string());
1554 if should_mark_error.is_none() {
1555 cx.notify();
1556 }
1557
1558 None
1559 }
1560 }
1561 };
1562 if !self.panels_with_errors.is_empty() {
1563 return None;
1564 }
1565 if query.as_ref().is_some_and(|query| query.is_empty()) {
1566 return None;
1567 }
1568 query
1569 }
1570
1571 fn open_buffers(&self, cx: &App, workspace: &Workspace) -> Vec<Entity<Buffer>> {
1572 let mut buffers = Vec::new();
1573 for editor in workspace.items_of_type::<Editor>(cx) {
1574 if let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() {
1575 buffers.push(buffer);
1576 }
1577 }
1578 buffers
1579 }
1580
1581 fn parse_path_matches(&self, text: String, cx: &App) -> anyhow::Result<PathMatcher> {
1582 let path_style = self.entity.read(cx).project.read(cx).path_style(cx);
1583 let queries = split_glob_patterns(&text)
1584 .into_iter()
1585 .map(str::trim)
1586 .filter(|maybe_glob_str| !maybe_glob_str.is_empty())
1587 .map(str::to_owned)
1588 .collect::<Vec<_>>();
1589 Ok(PathMatcher::new(&queries, path_style)?)
1590 }
1591
1592 fn select_match(&mut self, direction: Direction, window: &mut Window, cx: &mut Context<Self>) {
1593 if let Some(index) = self.active_match_index {
1594 let match_ranges = self.entity.read(cx).match_ranges.clone();
1595
1596 if !EditorSettings::get_global(cx).search_wrap
1597 && ((direction == Direction::Next && index + 1 >= match_ranges.len())
1598 || (direction == Direction::Prev && index == 0))
1599 {
1600 crate::show_no_more_matches(window, cx);
1601 return;
1602 }
1603
1604 let new_index = self.results_editor.update(cx, |editor, cx| {
1605 editor.match_index_for_direction(
1606 &match_ranges,
1607 index,
1608 direction,
1609 1,
1610 SearchToken::default(),
1611 window,
1612 cx,
1613 )
1614 });
1615
1616 self.select_match_range(&match_ranges, new_index, window, cx);
1617 }
1618 }
1619
1620 fn select_first_match(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1621 self.entity.clone().update(cx, |entity, cx| {
1622 if !entity.match_ranges.is_empty() {
1623 self.active_match_index = Some(0);
1624 self.select_match_range(&entity.match_ranges, 0, window, cx);
1625 }
1626 })
1627 }
1628
1629 fn select_match_range(
1630 &mut self,
1631 match_ranges: &[Range<Anchor>],
1632 index: usize,
1633 window: &mut Window,
1634 cx: &mut App,
1635 ) {
1636 let Some(range) = match_ranges.get(index) else {
1637 return;
1638 };
1639 self.results_editor.update(cx, |editor, cx| {
1640 let range_to_select = editor.range_for_match(range);
1641 let autoscroll = if EditorSettings::get_global(cx).search.center_on_match {
1642 Autoscroll::center()
1643 } else {
1644 Autoscroll::fit()
1645 };
1646 editor.unfold_ranges(std::slice::from_ref(&range_to_select), false, true, cx);
1647 editor.change_selections(SelectionEffects::scroll(autoscroll), window, cx, |s| {
1648 s.select_ranges([range_to_select])
1649 });
1650 });
1651 self.highlight_matches(match_ranges, Some(index), cx);
1652 }
1653
1654 fn focus_query_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1655 self.query_editor.update(cx, |query_editor, cx| {
1656 query_editor.select_all(&SelectAll, window, cx);
1657 });
1658 let editor_handle = self.query_editor.focus_handle(cx);
1659 window.focus(&editor_handle, cx);
1660 }
1661
1662 fn set_query(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
1663 self.set_search_editor(SearchInputKind::Query, query, window, cx);
1664 if EditorSettings::get_global(cx).use_smartcase_search
1665 && !query.is_empty()
1666 && self.search_options.contains(SearchOptions::CASE_SENSITIVE)
1667 != contains_uppercase(query)
1668 {
1669 self.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx)
1670 }
1671 }
1672
1673 fn set_search_editor(
1674 &mut self,
1675 kind: SearchInputKind,
1676 text: &str,
1677 window: &mut Window,
1678 cx: &mut Context<Self>,
1679 ) {
1680 let editor = match kind {
1681 SearchInputKind::Query => &self.query_editor,
1682 SearchInputKind::Include => &self.included_files_editor,
1683 SearchInputKind::Exclude => &self.excluded_files_editor,
1684 };
1685 editor.update(cx, |editor, cx| {
1686 editor.set_text(text, window, cx);
1687 editor.request_autoscroll(Autoscroll::fit(), cx);
1688 });
1689 }
1690
1691 fn focus_results_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1692 self.query_editor.update(cx, |query_editor, cx| {
1693 let cursor = query_editor.selections.newest_anchor().head();
1694 query_editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
1695 s.select_ranges([cursor..cursor])
1696 });
1697 });
1698 let results_handle = self.results_editor.focus_handle(cx);
1699 window.focus(&results_handle, cx);
1700 }
1701
1702 fn entity_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1703 let model = self.entity.read(cx);
1704 let search_input_confirmed = model.search_input_confirmed;
1705 let match_ranges = model.match_ranges.clone();
1706 let is_incremental_pending =
1707 model.pending_search.is_some() && EditorSettings::get_global(cx).search.search_on_input;
1708
1709 if match_ranges.is_empty() {
1710 if !is_incremental_pending {
1711 self.active_match_index = None;
1712 }
1713 self.results_editor.update(cx, |editor, cx| {
1714 editor.clear_background_highlights(HighlightKey::ProjectSearchView, cx);
1715 });
1716 } else {
1717 self.active_match_index = Some(0);
1718 self.update_match_index(cx);
1719 let prev_search_id = mem::replace(&mut self.search_id, self.entity.read(cx).search_id);
1720 let is_new_search = self.search_id != prev_search_id;
1721 self.results_editor.update(cx, |editor, cx| {
1722 if is_new_search {
1723 let range_to_select = match_ranges
1724 .first()
1725 .map(|range| editor.range_for_match(range));
1726 editor.change_selections(Default::default(), window, cx, |s| {
1727 s.select_ranges(range_to_select)
1728 });
1729 editor.scroll(Point::default(), Some(Axis::Vertical), window, cx);
1730 }
1731 });
1732 let should_auto_focus =
1733 search_input_confirmed || !EditorSettings::get_global(cx).search.search_on_input;
1734 if is_new_search
1735 && self.query_editor.focus_handle(cx).is_focused(window)
1736 && should_auto_focus
1737 {
1738 self.focus_results_editor(window, cx);
1739 }
1740 }
1741
1742 cx.emit(ViewEvent::UpdateTab);
1743 cx.notify();
1744
1745 if self.pending_replace_all && self.entity.read(cx).pending_search.is_none() {
1746 self.replace_all(&ReplaceAll, window, cx);
1747 }
1748 }
1749
1750 fn update_match_index(&mut self, cx: &mut Context<Self>) {
1751 let results_editor = self.results_editor.read(cx);
1752 let newest_anchor = results_editor.selections.newest_anchor().head();
1753 let buffer_snapshot = results_editor.buffer().read(cx).snapshot(cx);
1754 let new_index = self.entity.update(cx, |this, cx| {
1755 let new_index = active_match_index(
1756 Direction::Next,
1757 &this.match_ranges,
1758 &newest_anchor,
1759 &buffer_snapshot,
1760 );
1761
1762 self.highlight_matches(&this.match_ranges, new_index, cx);
1763 new_index
1764 });
1765
1766 if self.active_match_index != new_index {
1767 self.active_match_index = new_index;
1768 cx.notify();
1769 }
1770 }
1771
1772 #[ztracing::instrument(skip_all)]
1773 fn highlight_matches(
1774 &self,
1775 match_ranges: &[Range<Anchor>],
1776 active_index: Option<usize>,
1777 cx: &mut App,
1778 ) {
1779 self.results_editor.update(cx, |editor, cx| {
1780 editor.highlight_background(
1781 HighlightKey::ProjectSearchView,
1782 match_ranges,
1783 move |index, theme| {
1784 if active_index == Some(*index) {
1785 theme.colors().search_active_match_background
1786 } else {
1787 theme.colors().search_match_background
1788 }
1789 },
1790 cx,
1791 );
1792 });
1793 }
1794
1795 pub fn has_matches(&self) -> bool {
1796 self.active_match_index.is_some()
1797 }
1798
1799 fn landing_text_minor(&self, cx: &App) -> impl IntoElement {
1800 let focus_handle = self.focus_handle.clone();
1801 v_flex()
1802 .gap_1()
1803 .child(
1804 Label::new(if EditorSettings::get_global(cx).search.search_on_input {
1805 "Start typing to search. For more options:"
1806 } else {
1807 "Hit enter to search. For more options:"
1808 })
1809 .color(Color::Muted)
1810 .mb_2(),
1811 )
1812 .child(
1813 Button::new("filter-paths", "Include/exclude specific paths")
1814 .start_icon(Icon::new(IconName::Filter).size(IconSize::Small))
1815 .key_binding(KeyBinding::for_action_in(&ToggleFilters, &focus_handle, cx))
1816 .on_click(|_event, window, cx| {
1817 window.dispatch_action(ToggleFilters.boxed_clone(), cx)
1818 }),
1819 )
1820 .child(
1821 Button::new("find-replace", "Find and replace")
1822 .start_icon(Icon::new(IconName::Replace).size(IconSize::Small))
1823 .key_binding(KeyBinding::for_action_in(&ToggleReplace, &focus_handle, cx))
1824 .on_click(|_event, window, cx| {
1825 window.dispatch_action(ToggleReplace.boxed_clone(), cx)
1826 }),
1827 )
1828 .child(
1829 Button::new("regex", "Match with regex")
1830 .start_icon(Icon::new(IconName::Regex).size(IconSize::Small))
1831 .key_binding(KeyBinding::for_action_in(&ToggleRegex, &focus_handle, cx))
1832 .on_click(|_event, window, cx| {
1833 window.dispatch_action(ToggleRegex.boxed_clone(), cx)
1834 }),
1835 )
1836 .child(
1837 Button::new("match-case", "Match case")
1838 .start_icon(Icon::new(IconName::CaseSensitive).size(IconSize::Small))
1839 .key_binding(KeyBinding::for_action_in(
1840 &ToggleCaseSensitive,
1841 &focus_handle,
1842 cx,
1843 ))
1844 .on_click(|_event, window, cx| {
1845 window.dispatch_action(ToggleCaseSensitive.boxed_clone(), cx)
1846 }),
1847 )
1848 .child(
1849 Button::new("match-whole-words", "Match whole words")
1850 .start_icon(Icon::new(IconName::WholeWord).size(IconSize::Small))
1851 .key_binding(KeyBinding::for_action_in(
1852 &ToggleWholeWord,
1853 &focus_handle,
1854 cx,
1855 ))
1856 .on_click(|_event, window, cx| {
1857 window.dispatch_action(ToggleWholeWord.boxed_clone(), cx)
1858 }),
1859 )
1860 }
1861
1862 fn border_color_for(&self, panel: InputPanel, cx: &App) -> Hsla {
1863 if self.panels_with_errors.contains_key(&panel) {
1864 Color::Error.color(cx)
1865 } else {
1866 cx.theme().colors().border
1867 }
1868 }
1869
1870 fn move_focus_to_results(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1871 if !self.results_editor.focus_handle(cx).is_focused(window)
1872 && !self.entity.read(cx).match_ranges.is_empty()
1873 {
1874 cx.stop_propagation();
1875 self.focus_results_editor(window, cx)
1876 }
1877 }
1878
1879 #[cfg(any(test, feature = "test-support"))]
1880 pub fn results_editor(&self) -> &Entity<Editor> {
1881 &self.results_editor
1882 }
1883
1884 fn adjust_query_regex_language(&self, cx: &mut App) {
1885 let enable = self.search_options.contains(SearchOptions::REGEX);
1886 let query_buffer = self
1887 .query_editor
1888 .read(cx)
1889 .buffer()
1890 .read(cx)
1891 .as_singleton()
1892 .expect("query editor should be backed by a singleton buffer");
1893 if enable {
1894 if let Some(regex_language) = self.regex_language.clone() {
1895 query_buffer.update(cx, |query_buffer, cx| {
1896 query_buffer.set_language(Some(regex_language), cx);
1897 })
1898 }
1899 } else {
1900 query_buffer.update(cx, |query_buffer, cx| {
1901 query_buffer.set_language(None, cx);
1902 })
1903 }
1904 }
1905}
1906
1907fn buffer_search_query(
1908 workspace: &mut Workspace,
1909 item: &dyn ItemHandle,
1910 cx: &mut Context<Workspace>,
1911) -> Option<String> {
1912 let buffer_search_bar = workspace
1913 .pane_for(item)
1914 .and_then(|pane| {
1915 pane.read(cx)
1916 .toolbar()
1917 .read(cx)
1918 .item_of_type::<BufferSearchBar>()
1919 })?
1920 .read(cx);
1921 if buffer_search_bar.query_editor_focused() {
1922 let buffer_search_query = buffer_search_bar.query(cx);
1923 if !buffer_search_query.is_empty() {
1924 return Some(buffer_search_query);
1925 }
1926 }
1927 None
1928}
1929
1930impl Default for ProjectSearchBar {
1931 fn default() -> Self {
1932 Self::new()
1933 }
1934}
1935
1936impl ProjectSearchBar {
1937 pub fn new() -> Self {
1938 Self {
1939 active_project_search: None,
1940 subscription: None,
1941 }
1942 }
1943
1944 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
1945 if let Some(search_view) = self.active_project_search.as_ref() {
1946 search_view.update(cx, |search_view, cx| {
1947 if search_view
1948 .replacement_editor
1949 .focus_handle(cx)
1950 .is_focused(window)
1951 {
1952 return;
1953 }
1954
1955 cx.stop_propagation();
1956 if EditorSettings::get_global(cx).search.search_on_input {
1957 // Results are already available from incremental search.
1958 // Mark confirmed so entity_changed auto-focuses when
1959 // a pending search completes, and select the first match
1960 // if results already exist.
1961 search_view
1962 .entity
1963 .update(cx, |model, _| model.search_input_confirmed = true);
1964 if search_view.has_matches() {
1965 search_view.select_first_match(window, cx);
1966 search_view.focus_results_editor(window, cx);
1967 }
1968 } else {
1969 search_view
1970 .prompt_to_save_if_dirty_then_search(window, cx)
1971 .detach_and_log_err(cx);
1972 }
1973 });
1974 }
1975 }
1976
1977 fn tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context<Self>) {
1978 self.cycle_field(Direction::Next, window, cx);
1979 }
1980
1981 fn backtab(&mut self, _: &Backtab, window: &mut Window, cx: &mut Context<Self>) {
1982 self.cycle_field(Direction::Prev, window, cx);
1983 }
1984
1985 fn focus_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1986 if let Some(search_view) = self.active_project_search.as_ref() {
1987 search_view.update(cx, |search_view, cx| {
1988 search_view.query_editor.focus_handle(cx).focus(window, cx);
1989 });
1990 }
1991 }
1992
1993 fn cycle_field(&mut self, direction: Direction, window: &mut Window, cx: &mut Context<Self>) {
1994 let active_project_search = match &self.active_project_search {
1995 Some(active_project_search) => active_project_search,
1996 None => return,
1997 };
1998
1999 active_project_search.update(cx, |project_view, cx| {
2000 let mut views = vec![project_view.query_editor.focus_handle(cx)];
2001 if project_view.replace_enabled {
2002 views.push(project_view.replacement_editor.focus_handle(cx));
2003 }
2004 if project_view.filters_enabled {
2005 views.extend([
2006 project_view.included_files_editor.focus_handle(cx),
2007 project_view.excluded_files_editor.focus_handle(cx),
2008 ]);
2009 }
2010 let current_index = match views.iter().position(|focus| focus.is_focused(window)) {
2011 Some(index) => index,
2012 None => return,
2013 };
2014
2015 let new_index = match direction {
2016 Direction::Next => (current_index + 1) % views.len(),
2017 Direction::Prev if current_index == 0 => views.len() - 1,
2018 Direction::Prev => (current_index - 1) % views.len(),
2019 };
2020 let next_focus_handle = &views[new_index];
2021 window.focus(next_focus_handle, cx);
2022 cx.stop_propagation();
2023 });
2024 }
2025
2026 pub(crate) fn toggle_search_option(
2027 &mut self,
2028 option: SearchOptions,
2029 window: &mut Window,
2030 cx: &mut Context<Self>,
2031 ) -> bool {
2032 if self.active_project_search.is_none() {
2033 return false;
2034 }
2035
2036 cx.spawn_in(window, async move |this, cx| {
2037 let task = this.update_in(cx, |this, window, cx| {
2038 let search_view = this.active_project_search.as_ref()?;
2039 search_view.update(cx, |search_view, cx| {
2040 search_view.toggle_search_option(option, cx);
2041 search_view
2042 .entity
2043 .read(cx)
2044 .active_query
2045 .is_some()
2046 .then(|| search_view.prompt_to_save_if_dirty_then_search(window, cx))
2047 })
2048 })?;
2049 if let Some(task) = task {
2050 task.await?;
2051 }
2052 this.update(cx, |_, cx| {
2053 cx.notify();
2054 })?;
2055 anyhow::Ok(())
2056 })
2057 .detach();
2058 true
2059 }
2060
2061 fn toggle_replace(&mut self, _: &ToggleReplace, window: &mut Window, cx: &mut Context<Self>) {
2062 if let Some(search) = &self.active_project_search {
2063 search.update(cx, |this, cx| {
2064 this.replace_enabled = !this.replace_enabled;
2065 let editor_to_focus = if this.replace_enabled {
2066 this.replacement_editor.focus_handle(cx)
2067 } else {
2068 this.query_editor.focus_handle(cx)
2069 };
2070 window.focus(&editor_to_focus, cx);
2071 cx.notify();
2072 });
2073 }
2074 }
2075
2076 fn toggle_filters(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
2077 if let Some(search_view) = self.active_project_search.as_ref() {
2078 search_view.update(cx, |search_view, cx| {
2079 search_view.toggle_filters(cx);
2080 search_view
2081 .included_files_editor
2082 .update(cx, |_, cx| cx.notify());
2083 search_view
2084 .excluded_files_editor
2085 .update(cx, |_, cx| cx.notify());
2086 window.refresh();
2087 cx.notify();
2088 });
2089 cx.notify();
2090 true
2091 } else {
2092 false
2093 }
2094 }
2095
2096 fn toggle_opened_only(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
2097 if self.active_project_search.is_none() {
2098 return false;
2099 }
2100
2101 cx.spawn_in(window, async move |this, cx| {
2102 let task = this.update_in(cx, |this, window, cx| {
2103 let search_view = this.active_project_search.as_ref()?;
2104 search_view.update(cx, |search_view, cx| {
2105 search_view.toggle_opened_only(window, cx);
2106 search_view
2107 .entity
2108 .read(cx)
2109 .active_query
2110 .is_some()
2111 .then(|| search_view.prompt_to_save_if_dirty_then_search(window, cx))
2112 })
2113 })?;
2114 if let Some(task) = task {
2115 task.await?;
2116 }
2117 this.update(cx, |_, cx| {
2118 cx.notify();
2119 })?;
2120 anyhow::Ok(())
2121 })
2122 .detach();
2123 true
2124 }
2125
2126 fn is_opened_only_enabled(&self, cx: &App) -> bool {
2127 if let Some(search_view) = self.active_project_search.as_ref() {
2128 search_view.read(cx).included_opened_only
2129 } else {
2130 false
2131 }
2132 }
2133
2134 fn move_focus_to_results(&self, window: &mut Window, cx: &mut Context<Self>) {
2135 if let Some(search_view) = self.active_project_search.as_ref() {
2136 search_view.update(cx, |search_view, cx| {
2137 search_view.move_focus_to_results(window, cx);
2138 });
2139 cx.notify();
2140 }
2141 }
2142
2143 fn next_history_query(
2144 &mut self,
2145 _: &NextHistoryQuery,
2146 window: &mut Window,
2147 cx: &mut Context<Self>,
2148 ) {
2149 if let Some(search_view) = self.active_project_search.as_ref() {
2150 search_view.update(cx, |search_view, cx| {
2151 for (editor, kind) in [
2152 (search_view.query_editor.clone(), SearchInputKind::Query),
2153 (
2154 search_view.included_files_editor.clone(),
2155 SearchInputKind::Include,
2156 ),
2157 (
2158 search_view.excluded_files_editor.clone(),
2159 SearchInputKind::Exclude,
2160 ),
2161 ] {
2162 if editor.focus_handle(cx).is_focused(window) {
2163 if !should_navigate_history(&editor, HistoryNavigationDirection::Next, cx) {
2164 cx.propagate();
2165 return;
2166 }
2167
2168 let new_query = search_view.entity.update(cx, |model, cx| {
2169 let project = model.project.clone();
2170
2171 if let Some(new_query) = project.update(cx, |project, _| {
2172 project
2173 .search_history_mut(kind)
2174 .next(model.cursor_mut(kind))
2175 .map(str::to_string)
2176 }) {
2177 Some(new_query)
2178 } else {
2179 model.cursor_mut(kind).take_draft()
2180 }
2181 });
2182 if let Some(new_query) = new_query {
2183 search_view.set_search_editor(kind, &new_query, window, cx);
2184 }
2185 }
2186 }
2187 });
2188 }
2189 }
2190
2191 fn previous_history_query(
2192 &mut self,
2193 _: &PreviousHistoryQuery,
2194 window: &mut Window,
2195 cx: &mut Context<Self>,
2196 ) {
2197 if let Some(search_view) = self.active_project_search.as_ref() {
2198 search_view.update(cx, |search_view, cx| {
2199 for (editor, kind) in [
2200 (search_view.query_editor.clone(), SearchInputKind::Query),
2201 (
2202 search_view.included_files_editor.clone(),
2203 SearchInputKind::Include,
2204 ),
2205 (
2206 search_view.excluded_files_editor.clone(),
2207 SearchInputKind::Exclude,
2208 ),
2209 ] {
2210 if editor.focus_handle(cx).is_focused(window) {
2211 if !should_navigate_history(
2212 &editor,
2213 HistoryNavigationDirection::Previous,
2214 cx,
2215 ) {
2216 cx.propagate();
2217 return;
2218 }
2219
2220 if editor.read(cx).text(cx).is_empty()
2221 && let Some(new_query) = search_view
2222 .entity
2223 .read(cx)
2224 .project
2225 .read(cx)
2226 .search_history(kind)
2227 .current(search_view.entity.read(cx).cursor(kind))
2228 .map(str::to_string)
2229 {
2230 search_view.set_search_editor(kind, &new_query, window, cx);
2231 return;
2232 }
2233
2234 let current_query = editor.read(cx).text(cx);
2235 if let Some(new_query) = search_view.entity.update(cx, |model, cx| {
2236 let project = model.project.clone();
2237 project.update(cx, |project, _| {
2238 project
2239 .search_history_mut(kind)
2240 .previous(model.cursor_mut(kind), ¤t_query)
2241 .map(str::to_string)
2242 })
2243 }) {
2244 search_view.set_search_editor(kind, &new_query, window, cx);
2245 }
2246 }
2247 }
2248 });
2249 }
2250 }
2251
2252 fn select_next_match(
2253 &mut self,
2254 _: &SelectNextMatch,
2255 window: &mut Window,
2256 cx: &mut Context<Self>,
2257 ) {
2258 if let Some(search) = self.active_project_search.as_ref() {
2259 search.update(cx, |this, cx| {
2260 this.select_match(Direction::Next, window, cx);
2261 })
2262 }
2263 }
2264
2265 fn select_prev_match(
2266 &mut self,
2267 _: &SelectPreviousMatch,
2268 window: &mut Window,
2269 cx: &mut Context<Self>,
2270 ) {
2271 if let Some(search) = self.active_project_search.as_ref() {
2272 search.update(cx, |this, cx| {
2273 this.select_match(Direction::Prev, window, cx);
2274 })
2275 }
2276 }
2277}
2278
2279impl Render for ProjectSearchBar {
2280 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2281 let Some(search) = self.active_project_search.clone() else {
2282 return div().into_any_element();
2283 };
2284 let search = search.read(cx);
2285 let focus_handle = search.focus_handle(cx);
2286
2287 let container_width = window.viewport_size().width;
2288 let input_width = SearchInputWidth::calc_width(container_width);
2289
2290 let input_base_styles = |panel: InputPanel| {
2291 input_base_styles(search.border_color_for(panel, cx), |div| match panel {
2292 InputPanel::Query | InputPanel::Replacement => div.w(input_width),
2293 InputPanel::Include | InputPanel::Exclude => div.flex_grow(),
2294 })
2295 };
2296 let theme_colors = cx.theme().colors();
2297 let project_search = search.entity.read(cx);
2298 let limit_reached = project_search.limit_reached;
2299 let is_search_underway = project_search.pending_search.is_some();
2300
2301 let color_override = match (
2302 &project_search.pending_search,
2303 project_search.no_results,
2304 &project_search.active_query,
2305 &project_search.last_search_query_text,
2306 ) {
2307 (None, Some(true), Some(q), Some(p)) if q.as_str() == p => Some(Color::Error),
2308 _ => None,
2309 };
2310
2311 let match_text = search
2312 .active_match_index
2313 .and_then(|index| {
2314 let index = index + 1;
2315 let match_quantity = project_search.match_ranges.len();
2316 if match_quantity > 0 {
2317 debug_assert!(match_quantity >= index);
2318 if limit_reached {
2319 Some(format!("{index}/{match_quantity}+"))
2320 } else {
2321 Some(format!("{index}/{match_quantity}"))
2322 }
2323 } else {
2324 None
2325 }
2326 })
2327 .unwrap_or_else(|| "0/0".to_string());
2328
2329 let query_focus = search.query_editor.focus_handle(cx);
2330
2331 let query_column = input_base_styles(InputPanel::Query)
2332 .on_action(cx.listener(|this, action, window, cx| this.confirm(action, window, cx)))
2333 .on_action(cx.listener(|this, action, window, cx| {
2334 this.previous_history_query(action, window, cx)
2335 }))
2336 .on_action(
2337 cx.listener(|this, action, window, cx| this.next_history_query(action, window, cx)),
2338 )
2339 .child(div().flex_1().py_1().child(render_text_input(
2340 &search.query_editor,
2341 color_override,
2342 cx,
2343 )))
2344 .child(
2345 h_flex()
2346 .gap_1()
2347 .child(SearchOption::CaseSensitive.as_button(
2348 search.search_options,
2349 SearchSource::Project(cx),
2350 focus_handle.clone(),
2351 ))
2352 .child(SearchOption::WholeWord.as_button(
2353 search.search_options,
2354 SearchSource::Project(cx),
2355 focus_handle.clone(),
2356 ))
2357 .child(SearchOption::Regex.as_button(
2358 search.search_options,
2359 SearchSource::Project(cx),
2360 focus_handle.clone(),
2361 )),
2362 );
2363
2364 let matches_column = h_flex()
2365 .ml_1()
2366 .pl_1p5()
2367 .border_l_1()
2368 .border_color(theme_colors.border_variant)
2369 .child(render_action_button(
2370 "project-search-nav-button",
2371 IconName::ChevronLeft,
2372 search
2373 .active_match_index
2374 .is_none()
2375 .then_some(ActionButtonState::Disabled),
2376 "Select Previous Match",
2377 &SelectPreviousMatch,
2378 query_focus.clone(),
2379 ))
2380 .child(render_action_button(
2381 "project-search-nav-button",
2382 IconName::ChevronRight,
2383 search
2384 .active_match_index
2385 .is_none()
2386 .then_some(ActionButtonState::Disabled),
2387 "Select Next Match",
2388 &SelectNextMatch,
2389 query_focus.clone(),
2390 ))
2391 .child(
2392 div()
2393 .id("matches")
2394 .ml_2()
2395 .min_w(rems_from_px(40.))
2396 .child(
2397 h_flex()
2398 .gap_1p5()
2399 .child(
2400 Label::new(match_text)
2401 .size(LabelSize::Small)
2402 .when(search.active_match_index.is_some(), |this| {
2403 this.color(Color::Disabled)
2404 }),
2405 )
2406 .when(is_search_underway, |this| {
2407 this.child(
2408 Icon::new(IconName::ArrowCircle)
2409 .color(Color::Accent)
2410 .size(IconSize::Small)
2411 .with_rotate_animation(2)
2412 .into_any_element(),
2413 )
2414 }),
2415 )
2416 .when(limit_reached, |this| {
2417 this.tooltip(Tooltip::text(
2418 "Search Limits Reached\nTry narrowing your search",
2419 ))
2420 }),
2421 );
2422
2423 let mode_column = h_flex()
2424 .gap_1()
2425 .min_w_64()
2426 .child(
2427 IconButton::new("project-search-filter-button", IconName::Filter)
2428 .shape(IconButtonShape::Square)
2429 .tooltip(|_window, cx| {
2430 Tooltip::for_action("Toggle Filters", &ToggleFilters, cx)
2431 })
2432 .on_click(cx.listener(|this, _, window, cx| {
2433 this.toggle_filters(window, cx);
2434 }))
2435 .toggle_state(
2436 self.active_project_search
2437 .as_ref()
2438 .map(|search| search.read(cx).filters_enabled)
2439 .unwrap_or_default(),
2440 )
2441 .tooltip({
2442 let focus_handle = focus_handle.clone();
2443 move |_window, cx| {
2444 Tooltip::for_action_in(
2445 "Toggle Filters",
2446 &ToggleFilters,
2447 &focus_handle,
2448 cx,
2449 )
2450 }
2451 }),
2452 )
2453 .child(render_action_button(
2454 "project-search",
2455 IconName::Replace,
2456 self.active_project_search
2457 .as_ref()
2458 .map(|search| search.read(cx).replace_enabled)
2459 .and_then(|enabled| enabled.then_some(ActionButtonState::Toggled)),
2460 "Toggle Replace",
2461 &ToggleReplace,
2462 focus_handle.clone(),
2463 ))
2464 .child(matches_column);
2465
2466 let is_collapsed = search.results_editor.read(cx).has_any_buffer_folded(cx);
2467
2468 let (icon, tooltip_label) = if is_collapsed {
2469 (IconName::ChevronUpDown, "Expand All Search Results")
2470 } else {
2471 (IconName::ChevronDownUp, "Collapse All Search Results")
2472 };
2473
2474 let expand_button = IconButton::new("project-search-collapse-expand", icon)
2475 .shape(IconButtonShape::Square)
2476 .tooltip(move |_, cx| {
2477 Tooltip::for_action_in(
2478 tooltip_label,
2479 &ToggleAllSearchResults,
2480 &query_focus.clone(),
2481 cx,
2482 )
2483 })
2484 .on_click(cx.listener(|this, _, window, cx| {
2485 if let Some(active_view) = &this.active_project_search {
2486 active_view.update(cx, |active_view, cx| {
2487 active_view.toggle_all_search_results(&ToggleAllSearchResults, window, cx);
2488 })
2489 }
2490 }));
2491
2492 let search_line = h_flex()
2493 .pl_0p5()
2494 .w_full()
2495 .gap_2()
2496 .child(expand_button)
2497 .child(query_column)
2498 .child(mode_column);
2499
2500 let replace_line = search.replace_enabled.then(|| {
2501 let replace_column = input_base_styles(InputPanel::Replacement).child(
2502 div().flex_1().py_1().child(render_text_input(
2503 &search.replacement_editor,
2504 None,
2505 cx,
2506 )),
2507 );
2508
2509 let focus_handle = search.replacement_editor.read(cx).focus_handle(cx);
2510 let replace_actions = h_flex()
2511 .min_w_64()
2512 .gap_1()
2513 .child(render_action_button(
2514 "project-search-replace-button",
2515 IconName::ReplaceNext,
2516 is_search_underway.then_some(ActionButtonState::Disabled),
2517 "Replace Next Match",
2518 &ReplaceNext,
2519 focus_handle.clone(),
2520 ))
2521 .child(render_action_button(
2522 "project-search-replace-button",
2523 IconName::ReplaceAll,
2524 Default::default(),
2525 "Replace All Matches",
2526 &ReplaceAll,
2527 focus_handle,
2528 ));
2529
2530 h_flex()
2531 .w_full()
2532 .gap_2()
2533 .child(alignment_element())
2534 .child(replace_column)
2535 .child(replace_actions)
2536 });
2537
2538 let filter_line = search.filters_enabled.then(|| {
2539 let include = input_base_styles(InputPanel::Include)
2540 .on_action(cx.listener(|this, action, window, cx| {
2541 this.previous_history_query(action, window, cx)
2542 }))
2543 .on_action(cx.listener(|this, action, window, cx| {
2544 this.next_history_query(action, window, cx)
2545 }))
2546 .child(render_text_input(&search.included_files_editor, None, cx));
2547 let exclude = input_base_styles(InputPanel::Exclude)
2548 .on_action(cx.listener(|this, action, window, cx| {
2549 this.previous_history_query(action, window, cx)
2550 }))
2551 .on_action(cx.listener(|this, action, window, cx| {
2552 this.next_history_query(action, window, cx)
2553 }))
2554 .child(render_text_input(&search.excluded_files_editor, None, cx));
2555 let mode_column = h_flex()
2556 .gap_1()
2557 .min_w_64()
2558 .child(
2559 IconButton::new("project-search-opened-only", IconName::FolderSearch)
2560 .shape(IconButtonShape::Square)
2561 .toggle_state(self.is_opened_only_enabled(cx))
2562 .tooltip(Tooltip::text("Only Search Open Files"))
2563 .on_click(cx.listener(|this, _, window, cx| {
2564 this.toggle_opened_only(window, cx);
2565 })),
2566 )
2567 .child(SearchOption::IncludeIgnored.as_button(
2568 search.search_options,
2569 SearchSource::Project(cx),
2570 focus_handle,
2571 ));
2572
2573 h_flex()
2574 .w_full()
2575 .gap_2()
2576 .child(alignment_element())
2577 .child(
2578 h_flex()
2579 .w(input_width)
2580 .gap_2()
2581 .child(include)
2582 .child(exclude),
2583 )
2584 .child(mode_column)
2585 });
2586
2587 let mut key_context = KeyContext::default();
2588 key_context.add("ProjectSearchBar");
2589 if search
2590 .replacement_editor
2591 .focus_handle(cx)
2592 .is_focused(window)
2593 {
2594 key_context.add("in_replace");
2595 }
2596
2597 let query_error_line = search
2598 .panels_with_errors
2599 .get(&InputPanel::Query)
2600 .map(|error| {
2601 Label::new(error)
2602 .size(LabelSize::Small)
2603 .color(Color::Error)
2604 .mt_neg_1()
2605 .ml_2()
2606 });
2607
2608 let filter_error_line = search
2609 .panels_with_errors
2610 .get(&InputPanel::Include)
2611 .or_else(|| search.panels_with_errors.get(&InputPanel::Exclude))
2612 .map(|error| {
2613 Label::new(error)
2614 .size(LabelSize::Small)
2615 .color(Color::Error)
2616 .mt_neg_1()
2617 .ml_2()
2618 });
2619
2620 v_flex()
2621 .gap_2()
2622 .w_full()
2623 .key_context(key_context)
2624 .on_action(cx.listener(|this, _: &ToggleFocus, window, cx| {
2625 this.move_focus_to_results(window, cx)
2626 }))
2627 .on_action(cx.listener(|this, _: &ToggleFilters, window, cx| {
2628 this.toggle_filters(window, cx);
2629 }))
2630 .capture_action(cx.listener(Self::tab))
2631 .capture_action(cx.listener(Self::backtab))
2632 .on_action(cx.listener(|this, action, window, cx| this.confirm(action, window, cx)))
2633 .on_action(cx.listener(|this, action, window, cx| {
2634 this.toggle_replace(action, window, cx);
2635 }))
2636 .on_action(cx.listener(|this, _: &ToggleWholeWord, window, cx| {
2637 this.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx);
2638 }))
2639 .on_action(cx.listener(|this, _: &ToggleCaseSensitive, window, cx| {
2640 this.toggle_search_option(SearchOptions::CASE_SENSITIVE, window, cx);
2641 }))
2642 .on_action(cx.listener(|this, action, window, cx| {
2643 if let Some(search) = this.active_project_search.as_ref() {
2644 search.update(cx, |this, cx| {
2645 this.replace_next(action, window, cx);
2646 })
2647 }
2648 }))
2649 .on_action(cx.listener(|this, action, window, cx| {
2650 if let Some(search) = this.active_project_search.as_ref() {
2651 search.update(cx, |this, cx| {
2652 this.replace_all(action, window, cx);
2653 })
2654 }
2655 }))
2656 .when(search.filters_enabled, |this| {
2657 this.on_action(cx.listener(|this, _: &ToggleIncludeIgnored, window, cx| {
2658 this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, window, cx);
2659 }))
2660 })
2661 .on_action(cx.listener(Self::select_next_match))
2662 .on_action(cx.listener(Self::select_prev_match))
2663 .child(search_line)
2664 .children(query_error_line)
2665 .children(replace_line)
2666 .children(filter_line)
2667 .children(filter_error_line)
2668 .into_any_element()
2669 }
2670}
2671
2672impl EventEmitter<ToolbarItemEvent> for ProjectSearchBar {}
2673
2674impl ToolbarItemView for ProjectSearchBar {
2675 fn set_active_pane_item(
2676 &mut self,
2677 active_pane_item: Option<&dyn ItemHandle>,
2678 _: &mut Window,
2679 cx: &mut Context<Self>,
2680 ) -> ToolbarItemLocation {
2681 cx.notify();
2682 self.subscription = None;
2683 self.active_project_search = None;
2684 if let Some(search) = active_pane_item.and_then(|i| i.downcast::<ProjectSearchView>()) {
2685 self.subscription = Some(cx.observe(&search, |_, _, cx| cx.notify()));
2686 self.active_project_search = Some(search);
2687 ToolbarItemLocation::PrimaryLeft {}
2688 } else {
2689 ToolbarItemLocation::Hidden
2690 }
2691 }
2692}
2693
2694fn register_workspace_action<A: Action>(
2695 workspace: &mut Workspace,
2696 callback: fn(&mut ProjectSearchBar, &A, &mut Window, &mut Context<ProjectSearchBar>),
2697) {
2698 workspace.register_action(move |workspace, action: &A, window, cx| {
2699 if workspace.has_active_modal(window, cx) && !workspace.hide_modal(window, cx) {
2700 cx.propagate();
2701 return;
2702 }
2703
2704 workspace.active_pane().update(cx, |pane, cx| {
2705 pane.toolbar().update(cx, move |workspace, cx| {
2706 if let Some(search_bar) = workspace.item_of_type::<ProjectSearchBar>() {
2707 search_bar.update(cx, move |search_bar, cx| {
2708 if search_bar.active_project_search.is_some() {
2709 callback(search_bar, action, window, cx);
2710 cx.notify();
2711 } else {
2712 cx.propagate();
2713 }
2714 });
2715 }
2716 });
2717 })
2718 });
2719}
2720
2721fn register_workspace_action_for_present_search<A: Action>(
2722 workspace: &mut Workspace,
2723 callback: fn(&mut Workspace, &A, &mut Window, &mut Context<Workspace>),
2724) {
2725 workspace.register_action(move |workspace, action: &A, window, cx| {
2726 if workspace.has_active_modal(window, cx) && !workspace.hide_modal(window, cx) {
2727 cx.propagate();
2728 return;
2729 }
2730
2731 let should_notify = workspace
2732 .active_pane()
2733 .read(cx)
2734 .toolbar()
2735 .read(cx)
2736 .item_of_type::<ProjectSearchBar>()
2737 .map(|search_bar| search_bar.read(cx).active_project_search.is_some())
2738 .unwrap_or(false);
2739 if should_notify {
2740 callback(workspace, action, window, cx);
2741 cx.notify();
2742 } else {
2743 cx.propagate();
2744 }
2745 });
2746}
2747
2748#[cfg(any(test, feature = "test-support"))]
2749pub fn perform_project_search(
2750 search_view: &Entity<ProjectSearchView>,
2751 text: impl Into<std::sync::Arc<str>>,
2752 cx: &mut gpui::VisualTestContext,
2753) {
2754 cx.run_until_parked();
2755 search_view.update_in(cx, |search_view, window, cx| {
2756 search_view.query_editor.update(cx, |query_editor, cx| {
2757 query_editor.set_text(text, window, cx)
2758 });
2759 search_view.search(false, cx);
2760 });
2761 cx.run_until_parked();
2762}
2763
2764#[cfg(test)]
2765pub mod tests {
2766 use std::{
2767 cell::RefCell,
2768 path::PathBuf,
2769 rc::Rc,
2770 sync::{
2771 Arc,
2772 atomic::{self, AtomicUsize},
2773 },
2774 time::Duration,
2775 };
2776
2777 use super::*;
2778 use editor::{DisplayPoint, display_map::DisplayRow};
2779 use gpui::{Action, TestAppContext, VisualTestContext, WindowHandle};
2780 use language::{FakeLspAdapter, rust_lang};
2781 use multi_buffer::Event as MultiBufferEvent;
2782 use pretty_assertions::assert_eq;
2783 use project::{FakeFs, Fs};
2784 use serde_json::json;
2785 use settings::{
2786 InlayHintSettingsContent, SettingsStore, ThemeColorsContent, ThemeStyleContent,
2787 };
2788 use util::{path, paths::PathStyle, rel_path::rel_path};
2789 use util_macros::perf;
2790 use workspace::{DeploySearch, MultiWorkspace};
2791
2792 fn take_updated_files(events: &RefCell<Vec<MultiBufferEvent>>) -> Vec<String> {
2793 let mut files: Vec<String> = Vec::new();
2794 for event in events.borrow_mut().drain(..) {
2795 if let MultiBufferEvent::BufferRangesUpdated { path_key, .. } = event {
2796 let name = path_key.path.as_unix_str().to_owned();
2797 if !files.contains(&name) {
2798 files.push(name);
2799 }
2800 }
2801 }
2802 files.sort();
2803 files
2804 }
2805
2806 fn subscribe_to_excerpt_events(
2807 search: &Entity<ProjectSearch>,
2808 cx: &mut TestAppContext,
2809 ) -> (Rc<RefCell<Vec<MultiBufferEvent>>>, Subscription) {
2810 let events: Rc<RefCell<Vec<MultiBufferEvent>>> = Rc::default();
2811 let excerpts = cx.update(|cx| search.read(cx).excerpts.clone());
2812 let subscription = cx.update({
2813 let events = events.clone();
2814 |cx| {
2815 cx.subscribe(&excerpts, move |_, event: &MultiBufferEvent, _| {
2816 events.borrow_mut().push(event.clone());
2817 })
2818 }
2819 });
2820 (events, subscription)
2821 }
2822
2823 fn init_test(cx: &mut TestAppContext) {
2824 cx.update(|cx| {
2825 let settings = SettingsStore::test(cx);
2826 cx.set_global(settings);
2827
2828 theme::init(theme::LoadThemes::JustBase, cx);
2829 theme_settings::init(theme::LoadThemes::JustBase, cx);
2830
2831 editor::init(cx);
2832 crate::init(cx);
2833
2834 SettingsStore::update_global(cx, |store, cx| {
2835 store.update_user_settings(cx, |settings| {
2836 settings
2837 .editor
2838 .search
2839 .get_or_insert_default()
2840 .search_on_input = Some(false);
2841 });
2842 });
2843 });
2844 }
2845
2846 fn perform_search(
2847 search_view: WindowHandle<ProjectSearchView>,
2848 text: impl Into<Arc<str>>,
2849 cx: &mut TestAppContext,
2850 ) {
2851 search_view
2852 .update(cx, |search_view, window, cx| {
2853 search_view.query_editor.update(cx, |query_editor, cx| {
2854 query_editor.set_text(text, window, cx)
2855 });
2856 search_view.search(false, cx);
2857 })
2858 .unwrap();
2859 // Ensure editor highlights appear after the search is done
2860 cx.executor().advance_clock(
2861 editor::SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT + Duration::from_millis(100),
2862 );
2863 cx.background_executor.run_until_parked();
2864 }
2865
2866 fn perform_incremental_search(
2867 search_view: WindowHandle<ProjectSearchView>,
2868 text: impl Into<Arc<str>>,
2869 cx: &mut TestAppContext,
2870 ) {
2871 search_view
2872 .update(cx, |search_view, window, cx| {
2873 search_view.query_editor.update(cx, |query_editor, cx| {
2874 query_editor.set_text(text, window, cx)
2875 });
2876 search_view.search(true, cx);
2877 })
2878 .unwrap();
2879 cx.executor().advance_clock(
2880 editor::SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT + Duration::from_millis(100),
2881 );
2882 cx.background_executor.run_until_parked();
2883 }
2884
2885 fn read_match_count(
2886 search_view: WindowHandle<ProjectSearchView>,
2887 cx: &mut TestAppContext,
2888 ) -> usize {
2889 search_view
2890 .read_with(cx, |search_view, cx| {
2891 search_view.entity.read(cx).match_ranges.len()
2892 })
2893 .unwrap()
2894 }
2895
2896 fn read_match_texts(
2897 search_view: WindowHandle<ProjectSearchView>,
2898 cx: &mut TestAppContext,
2899 ) -> Vec<String> {
2900 search_view
2901 .read_with(cx, |search_view, cx| {
2902 let search = search_view.entity.read(cx);
2903 let snapshot = search.excerpts.read(cx).snapshot(cx);
2904 search
2905 .match_ranges
2906 .iter()
2907 .map(|range| snapshot.text_for_range(range.clone()).collect::<String>())
2908 .collect()
2909 })
2910 .unwrap()
2911 }
2912
2913 fn assert_all_highlights_match_query(
2914 search_view: WindowHandle<ProjectSearchView>,
2915 query: &str,
2916 cx: &mut TestAppContext,
2917 ) {
2918 let match_texts = read_match_texts(search_view, cx);
2919 assert_eq!(
2920 match_texts.len(),
2921 read_match_count(search_view, cx),
2922 "match texts count should equal match_ranges count for query {query:?}"
2923 );
2924 for text in &match_texts {
2925 assert_eq!(
2926 text.to_uppercase(),
2927 query.to_uppercase(),
2928 "every highlighted range should match the query {query:?}"
2929 );
2930 }
2931 }
2932
2933 #[test]
2934 fn test_split_glob_patterns() {
2935 assert_eq!(split_glob_patterns("a,b,c"), vec!["a", "b", "c"]);
2936 assert_eq!(split_glob_patterns("a, b, c"), vec!["a", " b", " c"]);
2937 assert_eq!(
2938 split_glob_patterns("src/{a,b}/**/*.rs"),
2939 vec!["src/{a,b}/**/*.rs"]
2940 );
2941 assert_eq!(
2942 split_glob_patterns("src/{a,b}/*.rs, tests/**/*.rs"),
2943 vec!["src/{a,b}/*.rs", " tests/**/*.rs"]
2944 );
2945 assert_eq!(split_glob_patterns("{a,b},{c,d}"), vec!["{a,b}", "{c,d}"]);
2946 assert_eq!(split_glob_patterns("{{a,b},{c,d}}"), vec!["{{a,b},{c,d}}"]);
2947 assert_eq!(split_glob_patterns(""), vec![""]);
2948 assert_eq!(split_glob_patterns("a"), vec!["a"]);
2949 // Escaped characters should not be treated as special
2950 assert_eq!(split_glob_patterns(r"a\,b,c"), vec![r"a\,b", "c"]);
2951 assert_eq!(split_glob_patterns(r"\{a,b\}"), vec![r"\{a", r"b\}"]);
2952 assert_eq!(split_glob_patterns(r"a\\,b"), vec![r"a\\", "b"]);
2953 assert_eq!(split_glob_patterns(r"a\\\,b"), vec![r"a\\\,b"]);
2954 }
2955
2956 #[perf]
2957 #[gpui::test]
2958 async fn test_project_search(cx: &mut TestAppContext) {
2959 fn dp(row: u32, col: u32) -> DisplayPoint {
2960 DisplayPoint::new(DisplayRow(row), col)
2961 }
2962
2963 fn assert_active_match_index(
2964 search_view: &WindowHandle<ProjectSearchView>,
2965 cx: &mut TestAppContext,
2966 expected_index: usize,
2967 ) {
2968 search_view
2969 .update(cx, |search_view, _window, _cx| {
2970 assert_eq!(search_view.active_match_index, Some(expected_index));
2971 })
2972 .unwrap();
2973 }
2974
2975 fn assert_selection_range(
2976 search_view: &WindowHandle<ProjectSearchView>,
2977 cx: &mut TestAppContext,
2978 expected_range: Range<DisplayPoint>,
2979 ) {
2980 search_view
2981 .update(cx, |search_view, _window, cx| {
2982 assert_eq!(
2983 search_view.results_editor.update(cx, |editor, cx| editor
2984 .selections
2985 .display_ranges(&editor.display_snapshot(cx))),
2986 [expected_range]
2987 );
2988 })
2989 .unwrap();
2990 }
2991
2992 fn assert_highlights(
2993 search_view: &WindowHandle<ProjectSearchView>,
2994 cx: &mut TestAppContext,
2995 expected_highlights: Vec<(Range<DisplayPoint>, &str)>,
2996 ) {
2997 search_view
2998 .update(cx, |search_view, window, cx| {
2999 let match_bg = cx.theme().colors().search_match_background;
3000 let active_match_bg = cx.theme().colors().search_active_match_background;
3001 let selection_bg = cx
3002 .theme()
3003 .colors()
3004 .editor_document_highlight_bracket_background;
3005
3006 let highlights: Vec<_> = expected_highlights
3007 .into_iter()
3008 .map(|(range, color_type)| {
3009 let color = match color_type {
3010 "active" => active_match_bg,
3011 "match" => match_bg,
3012 "selection" => selection_bg,
3013 _ => panic!("Unknown color type"),
3014 };
3015 (range, color)
3016 })
3017 .collect();
3018
3019 assert_eq!(
3020 search_view.results_editor.update(cx, |editor, cx| editor
3021 .all_text_background_highlights(window, cx)),
3022 highlights.as_slice()
3023 );
3024 })
3025 .unwrap();
3026 }
3027
3028 fn select_match(
3029 search_view: &WindowHandle<ProjectSearchView>,
3030 cx: &mut TestAppContext,
3031 direction: Direction,
3032 ) {
3033 search_view
3034 .update(cx, |search_view, window, cx| {
3035 search_view.select_match(direction, window, cx);
3036 })
3037 .unwrap();
3038 }
3039
3040 init_test(cx);
3041
3042 // Override active search match color since the fallback theme uses the same color
3043 // for normal search match and active one, which can make this test less robust.
3044 cx.update(|cx| {
3045 SettingsStore::update_global(cx, |settings, cx| {
3046 settings.update_user_settings(cx, |settings| {
3047 settings.theme.experimental_theme_overrides = Some(ThemeStyleContent {
3048 colors: ThemeColorsContent {
3049 search_active_match_background: Some("#ff0000ff".to_string()),
3050 ..Default::default()
3051 },
3052 ..Default::default()
3053 });
3054 });
3055 });
3056 });
3057
3058 let fs = FakeFs::new(cx.background_executor.clone());
3059 fs.insert_tree(
3060 path!("/dir"),
3061 json!({
3062 "one.rs": "const ONE: usize = 1;",
3063 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
3064 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
3065 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
3066 }),
3067 )
3068 .await;
3069 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3070 let window =
3071 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3072 let workspace = window
3073 .read_with(cx, |mw, _| mw.workspace().clone())
3074 .unwrap();
3075 let search = cx.new(|cx| ProjectSearch::new(project.clone(), cx));
3076 let search_view = cx.add_window(|window, cx| {
3077 ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
3078 });
3079
3080 perform_search(search_view, "TWO", cx);
3081 cx.run_until_parked();
3082
3083 search_view
3084 .update(cx, |search_view, _window, cx| {
3085 assert_eq!(
3086 search_view
3087 .results_editor
3088 .update(cx, |editor, cx| editor.display_text(cx)),
3089 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;"
3090 );
3091 })
3092 .unwrap();
3093
3094 assert_active_match_index(&search_view, cx, 0);
3095 assert_selection_range(&search_view, cx, dp(2, 32)..dp(2, 35));
3096 assert_highlights(
3097 &search_view,
3098 cx,
3099 vec![
3100 (dp(2, 32)..dp(2, 35), "active"),
3101 (dp(2, 37)..dp(2, 40), "selection"),
3102 (dp(2, 37)..dp(2, 40), "match"),
3103 (dp(5, 6)..dp(5, 9), "selection"),
3104 (dp(5, 6)..dp(5, 9), "match"),
3105 ],
3106 );
3107 select_match(&search_view, cx, Direction::Next);
3108 cx.run_until_parked();
3109
3110 assert_active_match_index(&search_view, cx, 1);
3111 assert_selection_range(&search_view, cx, dp(2, 37)..dp(2, 40));
3112 assert_highlights(
3113 &search_view,
3114 cx,
3115 vec![
3116 (dp(2, 32)..dp(2, 35), "selection"),
3117 (dp(2, 32)..dp(2, 35), "match"),
3118 (dp(2, 37)..dp(2, 40), "active"),
3119 (dp(5, 6)..dp(5, 9), "selection"),
3120 (dp(5, 6)..dp(5, 9), "match"),
3121 ],
3122 );
3123 select_match(&search_view, cx, Direction::Next);
3124 cx.run_until_parked();
3125
3126 assert_active_match_index(&search_view, cx, 2);
3127 assert_selection_range(&search_view, cx, dp(5, 6)..dp(5, 9));
3128 assert_highlights(
3129 &search_view,
3130 cx,
3131 vec![
3132 (dp(2, 32)..dp(2, 35), "selection"),
3133 (dp(2, 32)..dp(2, 35), "match"),
3134 (dp(2, 37)..dp(2, 40), "selection"),
3135 (dp(2, 37)..dp(2, 40), "match"),
3136 (dp(5, 6)..dp(5, 9), "active"),
3137 ],
3138 );
3139 select_match(&search_view, cx, Direction::Next);
3140 cx.run_until_parked();
3141
3142 assert_active_match_index(&search_view, cx, 0);
3143 assert_selection_range(&search_view, cx, dp(2, 32)..dp(2, 35));
3144 assert_highlights(
3145 &search_view,
3146 cx,
3147 vec![
3148 (dp(2, 32)..dp(2, 35), "active"),
3149 (dp(2, 37)..dp(2, 40), "selection"),
3150 (dp(2, 37)..dp(2, 40), "match"),
3151 (dp(5, 6)..dp(5, 9), "selection"),
3152 (dp(5, 6)..dp(5, 9), "match"),
3153 ],
3154 );
3155 select_match(&search_view, cx, Direction::Prev);
3156 cx.run_until_parked();
3157
3158 assert_active_match_index(&search_view, cx, 2);
3159 assert_selection_range(&search_view, cx, dp(5, 6)..dp(5, 9));
3160 assert_highlights(
3161 &search_view,
3162 cx,
3163 vec![
3164 (dp(2, 32)..dp(2, 35), "selection"),
3165 (dp(2, 32)..dp(2, 35), "match"),
3166 (dp(2, 37)..dp(2, 40), "selection"),
3167 (dp(2, 37)..dp(2, 40), "match"),
3168 (dp(5, 6)..dp(5, 9), "active"),
3169 ],
3170 );
3171 select_match(&search_view, cx, Direction::Prev);
3172 cx.run_until_parked();
3173
3174 assert_active_match_index(&search_view, cx, 1);
3175 assert_selection_range(&search_view, cx, dp(2, 37)..dp(2, 40));
3176 assert_highlights(
3177 &search_view,
3178 cx,
3179 vec![
3180 (dp(2, 32)..dp(2, 35), "selection"),
3181 (dp(2, 32)..dp(2, 35), "match"),
3182 (dp(2, 37)..dp(2, 40), "active"),
3183 (dp(5, 6)..dp(5, 9), "selection"),
3184 (dp(5, 6)..dp(5, 9), "match"),
3185 ],
3186 );
3187 search_view
3188 .update(cx, |search_view, window, cx| {
3189 search_view.results_editor.update(cx, |editor, cx| {
3190 editor.fold_all(&FoldAll, window, cx);
3191 })
3192 })
3193 .expect("Should fold fine");
3194 cx.run_until_parked();
3195
3196 let results_collapsed = search_view
3197 .read_with(cx, |search_view, cx| {
3198 search_view
3199 .results_editor
3200 .read(cx)
3201 .has_any_buffer_folded(cx)
3202 })
3203 .expect("got results_collapsed");
3204
3205 assert!(results_collapsed);
3206 search_view
3207 .update(cx, |search_view, window, cx| {
3208 search_view.results_editor.update(cx, |editor, cx| {
3209 editor.unfold_all(&UnfoldAll, window, cx);
3210 })
3211 })
3212 .expect("Should unfold fine");
3213 cx.run_until_parked();
3214
3215 let results_collapsed = search_view
3216 .read_with(cx, |search_view, cx| {
3217 search_view
3218 .results_editor
3219 .read(cx)
3220 .has_any_buffer_folded(cx)
3221 })
3222 .expect("got results_collapsed");
3223
3224 assert!(!results_collapsed);
3225 }
3226
3227 #[perf]
3228 #[gpui::test]
3229 async fn test_collapse_state_syncs_after_manual_buffer_fold(cx: &mut TestAppContext) {
3230 init_test(cx);
3231
3232 let fs = FakeFs::new(cx.background_executor.clone());
3233 fs.insert_tree(
3234 path!("/dir"),
3235 json!({
3236 "one.rs": "const ONE: usize = 1;",
3237 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
3238 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
3239 }),
3240 )
3241 .await;
3242 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3243 let window =
3244 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3245 let workspace = window
3246 .read_with(cx, |mw, _| mw.workspace().clone())
3247 .unwrap();
3248 let search = cx.new(|cx| ProjectSearch::new(project.clone(), cx));
3249 let search_view = cx.add_window(|window, cx| {
3250 ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
3251 });
3252
3253 // Search for "ONE" which appears in all 3 files
3254 perform_search(search_view, "ONE", cx);
3255
3256 // Verify initial state: no folds
3257 let has_any_folded = search_view
3258 .read_with(cx, |search_view, cx| {
3259 search_view
3260 .results_editor
3261 .read(cx)
3262 .has_any_buffer_folded(cx)
3263 })
3264 .expect("should read state");
3265 assert!(!has_any_folded, "No buffers should be folded initially");
3266
3267 // Fold all via fold_all
3268 search_view
3269 .update(cx, |search_view, window, cx| {
3270 search_view.results_editor.update(cx, |editor, cx| {
3271 editor.fold_all(&FoldAll, window, cx);
3272 })
3273 })
3274 .expect("Should fold fine");
3275 cx.run_until_parked();
3276
3277 let has_any_folded = search_view
3278 .read_with(cx, |search_view, cx| {
3279 search_view
3280 .results_editor
3281 .read(cx)
3282 .has_any_buffer_folded(cx)
3283 })
3284 .expect("should read state");
3285 assert!(
3286 has_any_folded,
3287 "All buffers should be folded after fold_all"
3288 );
3289
3290 // Manually unfold one buffer (simulating a chevron click)
3291 let first_buffer_id = search_view
3292 .read_with(cx, |search_view, cx| {
3293 search_view
3294 .results_editor
3295 .read(cx)
3296 .buffer()
3297 .read(cx)
3298 .snapshot(cx)
3299 .excerpts()
3300 .next()
3301 .unwrap()
3302 .context
3303 .start
3304 .buffer_id
3305 })
3306 .expect("should read buffer ids");
3307
3308 search_view
3309 .update(cx, |search_view, _window, cx| {
3310 search_view.results_editor.update(cx, |editor, cx| {
3311 editor.unfold_buffer(first_buffer_id, cx);
3312 })
3313 })
3314 .expect("Should unfold one buffer");
3315
3316 let has_any_folded = search_view
3317 .read_with(cx, |search_view, cx| {
3318 search_view
3319 .results_editor
3320 .read(cx)
3321 .has_any_buffer_folded(cx)
3322 })
3323 .expect("should read state");
3324 assert!(
3325 has_any_folded,
3326 "Should still report folds when only one buffer is unfolded"
3327 );
3328
3329 // Unfold all via unfold_all
3330 search_view
3331 .update(cx, |search_view, window, cx| {
3332 search_view.results_editor.update(cx, |editor, cx| {
3333 editor.unfold_all(&UnfoldAll, window, cx);
3334 })
3335 })
3336 .expect("Should unfold fine");
3337 cx.run_until_parked();
3338
3339 let has_any_folded = search_view
3340 .read_with(cx, |search_view, cx| {
3341 search_view
3342 .results_editor
3343 .read(cx)
3344 .has_any_buffer_folded(cx)
3345 })
3346 .expect("should read state");
3347 assert!(!has_any_folded, "No folds should remain after unfold_all");
3348
3349 // Manually fold one buffer back (simulating a chevron click)
3350 search_view
3351 .update(cx, |search_view, _window, cx| {
3352 search_view.results_editor.update(cx, |editor, cx| {
3353 editor.fold_buffer(first_buffer_id, cx);
3354 })
3355 })
3356 .expect("Should fold one buffer");
3357
3358 let has_any_folded = search_view
3359 .read_with(cx, |search_view, cx| {
3360 search_view
3361 .results_editor
3362 .read(cx)
3363 .has_any_buffer_folded(cx)
3364 })
3365 .expect("should read state");
3366 assert!(
3367 has_any_folded,
3368 "Should report folds after manually folding one buffer"
3369 );
3370 }
3371
3372 #[perf]
3373 #[gpui::test]
3374 async fn test_deploy_project_search_focus(cx: &mut TestAppContext) {
3375 init_test(cx);
3376
3377 let fs = FakeFs::new(cx.background_executor.clone());
3378 fs.insert_tree(
3379 "/dir",
3380 json!({
3381 "one.rs": "const ONE: usize = 1;",
3382 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
3383 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
3384 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
3385 }),
3386 )
3387 .await;
3388 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
3389 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
3390 let workspace = window
3391 .read_with(cx, |mw, _| mw.workspace().clone())
3392 .unwrap();
3393 let cx = &mut VisualTestContext::from_window(window.into(), cx);
3394 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3395
3396 let active_item = cx.read(|cx| {
3397 workspace
3398 .read(cx)
3399 .active_pane()
3400 .read(cx)
3401 .active_item()
3402 .and_then(|item| item.downcast::<ProjectSearchView>())
3403 });
3404 assert!(
3405 active_item.is_none(),
3406 "Expected no search panel to be active"
3407 );
3408
3409 workspace.update_in(cx, move |workspace, window, cx| {
3410 assert_eq!(workspace.panes().len(), 1);
3411 workspace.panes()[0].update(cx, |pane, cx| {
3412 pane.toolbar()
3413 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3414 });
3415
3416 ProjectSearchView::deploy_search(
3417 workspace,
3418 &workspace::DeploySearch::default(),
3419 window,
3420 cx,
3421 )
3422 });
3423
3424 let Some(search_view) = cx.read(|cx| {
3425 workspace
3426 .read(cx)
3427 .active_pane()
3428 .read(cx)
3429 .active_item()
3430 .and_then(|item| item.downcast::<ProjectSearchView>())
3431 }) else {
3432 panic!("Search view expected to appear after new search event trigger")
3433 };
3434
3435 cx.spawn(|mut cx| async move {
3436 window
3437 .update(&mut cx, |_, window, cx| {
3438 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3439 })
3440 .unwrap();
3441 })
3442 .detach();
3443 cx.background_executor.run_until_parked();
3444 window
3445 .update(cx, |_, window, cx| {
3446 search_view.update(cx, |search_view, cx| {
3447 assert!(
3448 search_view.query_editor.focus_handle(cx).is_focused(window),
3449 "Empty search view should be focused after the toggle focus event: no results panel to focus on",
3450 );
3451 });
3452 }).unwrap();
3453
3454 window
3455 .update(cx, |_, window, cx| {
3456 search_view.update(cx, |search_view, cx| {
3457 let query_editor = &search_view.query_editor;
3458 assert!(
3459 query_editor.focus_handle(cx).is_focused(window),
3460 "Search view should be focused after the new search view is activated",
3461 );
3462 let query_text = query_editor.read(cx).text(cx);
3463 assert!(
3464 query_text.is_empty(),
3465 "New search query should be empty but got '{query_text}'",
3466 );
3467 let results_text = search_view
3468 .results_editor
3469 .update(cx, |editor, cx| editor.display_text(cx));
3470 assert!(
3471 results_text.is_empty(),
3472 "Empty search view should have no results but got '{results_text}'"
3473 );
3474 });
3475 })
3476 .unwrap();
3477
3478 window
3479 .update(cx, |_, window, cx| {
3480 search_view.update(cx, |search_view, cx| {
3481 search_view.query_editor.update(cx, |query_editor, cx| {
3482 query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", window, cx)
3483 });
3484 search_view.search(false, cx);
3485 });
3486 })
3487 .unwrap();
3488 cx.background_executor.run_until_parked();
3489 window
3490 .update(cx, |_, window, cx| {
3491 search_view.update(cx, |search_view, cx| {
3492 let results_text = search_view
3493 .results_editor
3494 .update(cx, |editor, cx| editor.display_text(cx));
3495 assert!(
3496 results_text.is_empty(),
3497 "Search view for mismatching query should have no results but got '{results_text}'"
3498 );
3499 assert!(
3500 search_view.query_editor.focus_handle(cx).is_focused(window),
3501 "Search view should be focused after mismatching query had been used in search",
3502 );
3503 });
3504 }).unwrap();
3505
3506 cx.spawn(|mut cx| async move {
3507 window.update(&mut cx, |_, window, cx| {
3508 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3509 })
3510 })
3511 .detach();
3512 cx.background_executor.run_until_parked();
3513 window.update(cx, |_, window, cx| {
3514 search_view.update(cx, |search_view, cx| {
3515 assert!(
3516 search_view.query_editor.focus_handle(cx).is_focused(window),
3517 "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
3518 );
3519 });
3520 }).unwrap();
3521
3522 window
3523 .update(cx, |_, window, cx| {
3524 search_view.update(cx, |search_view, cx| {
3525 search_view.query_editor.update(cx, |query_editor, cx| {
3526 query_editor.set_text("TWO", window, cx)
3527 });
3528 search_view.search(false, cx);
3529 });
3530 })
3531 .unwrap();
3532 cx.background_executor.run_until_parked();
3533 window.update(cx, |_, window, cx| {
3534 search_view.update(cx, |search_view, cx| {
3535 assert_eq!(
3536 search_view
3537 .results_editor
3538 .update(cx, |editor, cx| editor.display_text(cx)),
3539 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
3540 "Search view results should match the query"
3541 );
3542 assert!(
3543 search_view.results_editor.focus_handle(cx).is_focused(window),
3544 "Search view with mismatching query should be focused after search results are available",
3545 );
3546 });
3547 }).unwrap();
3548 cx.spawn(|mut cx| async move {
3549 window
3550 .update(&mut cx, |_, window, cx| {
3551 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3552 })
3553 .unwrap();
3554 })
3555 .detach();
3556 cx.background_executor.run_until_parked();
3557 window.update(cx, |_, window, cx| {
3558 search_view.update(cx, |search_view, cx| {
3559 assert!(
3560 search_view.results_editor.focus_handle(cx).is_focused(window),
3561 "Search view with matching query should still have its results editor focused after the toggle focus event",
3562 );
3563 });
3564 }).unwrap();
3565
3566 workspace.update_in(cx, |workspace, window, cx| {
3567 ProjectSearchView::deploy_search(
3568 workspace,
3569 &workspace::DeploySearch::default(),
3570 window,
3571 cx,
3572 )
3573 });
3574 window.update(cx, |_, window, cx| {
3575 search_view.update(cx, |search_view, cx| {
3576 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");
3577 assert_eq!(
3578 search_view
3579 .results_editor
3580 .update(cx, |editor, cx| editor.display_text(cx)),
3581 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
3582 "Results should be unchanged after search view 2nd open in a row"
3583 );
3584 assert!(
3585 search_view.query_editor.focus_handle(cx).is_focused(window),
3586 "Focus should be moved into query editor again after search view 2nd open in a row"
3587 );
3588 });
3589 }).unwrap();
3590
3591 cx.spawn(|mut cx| async move {
3592 window
3593 .update(&mut cx, |_, window, cx| {
3594 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3595 })
3596 .unwrap();
3597 })
3598 .detach();
3599 cx.background_executor.run_until_parked();
3600 window.update(cx, |_, window, cx| {
3601 search_view.update(cx, |search_view, cx| {
3602 assert!(
3603 search_view.results_editor.focus_handle(cx).is_focused(window),
3604 "Search view with matching query should switch focus to the results editor after the toggle focus event",
3605 );
3606 });
3607 }).unwrap();
3608 }
3609
3610 #[perf]
3611 #[gpui::test]
3612 async fn test_filters_consider_toggle_state(cx: &mut TestAppContext) {
3613 init_test(cx);
3614
3615 let fs = FakeFs::new(cx.background_executor.clone());
3616 fs.insert_tree(
3617 "/dir",
3618 json!({
3619 "one.rs": "const ONE: usize = 1;",
3620 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
3621 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
3622 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
3623 }),
3624 )
3625 .await;
3626 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
3627 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
3628 let workspace = window
3629 .read_with(cx, |mw, _| mw.workspace().clone())
3630 .unwrap();
3631 let cx = &mut VisualTestContext::from_window(window.into(), cx);
3632 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3633
3634 workspace.update_in(cx, move |workspace, window, cx| {
3635 workspace.panes()[0].update(cx, |pane, cx| {
3636 pane.toolbar()
3637 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3638 });
3639
3640 ProjectSearchView::deploy_search(
3641 workspace,
3642 &workspace::DeploySearch::default(),
3643 window,
3644 cx,
3645 )
3646 });
3647
3648 let Some(search_view) = cx.read(|cx| {
3649 workspace
3650 .read(cx)
3651 .active_pane()
3652 .read(cx)
3653 .active_item()
3654 .and_then(|item| item.downcast::<ProjectSearchView>())
3655 }) else {
3656 panic!("Search view expected to appear after new search event trigger")
3657 };
3658
3659 cx.spawn(|mut cx| async move {
3660 window
3661 .update(&mut cx, |_, window, cx| {
3662 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3663 })
3664 .unwrap();
3665 })
3666 .detach();
3667 cx.background_executor.run_until_parked();
3668
3669 window
3670 .update(cx, |_, window, cx| {
3671 search_view.update(cx, |search_view, cx| {
3672 search_view.query_editor.update(cx, |query_editor, cx| {
3673 query_editor.set_text("const FOUR", window, cx)
3674 });
3675 search_view.toggle_filters(cx);
3676 search_view
3677 .excluded_files_editor
3678 .update(cx, |exclude_editor, cx| {
3679 exclude_editor.set_text("four.rs", window, cx)
3680 });
3681 search_view.search(false, cx);
3682 });
3683 })
3684 .unwrap();
3685 cx.background_executor.run_until_parked();
3686 window
3687 .update(cx, |_, _, cx| {
3688 search_view.update(cx, |search_view, cx| {
3689 let results_text = search_view
3690 .results_editor
3691 .update(cx, |editor, cx| editor.display_text(cx));
3692 assert!(
3693 results_text.is_empty(),
3694 "Search view for query with the only match in an excluded file should have no results but got '{results_text}'"
3695 );
3696 });
3697 }).unwrap();
3698
3699 cx.spawn(|mut cx| async move {
3700 window.update(&mut cx, |_, window, cx| {
3701 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3702 })
3703 })
3704 .detach();
3705 cx.background_executor.run_until_parked();
3706
3707 window
3708 .update(cx, |_, _, cx| {
3709 search_view.update(cx, |search_view, cx| {
3710 search_view.toggle_filters(cx);
3711 search_view.search(false, cx);
3712 });
3713 })
3714 .unwrap();
3715 cx.background_executor.run_until_parked();
3716 window
3717 .update(cx, |_, _, cx| {
3718 search_view.update(cx, |search_view, cx| {
3719 assert_eq!(
3720 search_view
3721 .results_editor
3722 .update(cx, |editor, cx| editor.display_text(cx)),
3723 "\n\nconst FOUR: usize = one::ONE + three::THREE;",
3724 "Search view results should contain the queried result in the previously excluded file with filters toggled off"
3725 );
3726 });
3727 })
3728 .unwrap();
3729 }
3730
3731 #[perf]
3732 #[gpui::test]
3733 async fn test_new_project_search_focus(cx: &mut TestAppContext) {
3734 init_test(cx);
3735
3736 let fs = FakeFs::new(cx.background_executor.clone());
3737 fs.insert_tree(
3738 path!("/dir"),
3739 json!({
3740 "one.rs": "const ONE: usize = 1;",
3741 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
3742 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
3743 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
3744 }),
3745 )
3746 .await;
3747 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3748 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
3749 let workspace = window
3750 .read_with(cx, |mw, _| mw.workspace().clone())
3751 .unwrap();
3752 let cx = &mut VisualTestContext::from_window(window.into(), cx);
3753 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3754
3755 let active_item = cx.read(|cx| {
3756 workspace
3757 .read(cx)
3758 .active_pane()
3759 .read(cx)
3760 .active_item()
3761 .and_then(|item| item.downcast::<ProjectSearchView>())
3762 });
3763 assert!(
3764 active_item.is_none(),
3765 "Expected no search panel to be active"
3766 );
3767
3768 workspace.update_in(cx, move |workspace, window, cx| {
3769 assert_eq!(workspace.panes().len(), 1);
3770 workspace.panes()[0].update(cx, |pane, cx| {
3771 pane.toolbar()
3772 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3773 });
3774
3775 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3776 });
3777
3778 let Some(search_view) = cx.read(|cx| {
3779 workspace
3780 .read(cx)
3781 .active_pane()
3782 .read(cx)
3783 .active_item()
3784 .and_then(|item| item.downcast::<ProjectSearchView>())
3785 }) else {
3786 panic!("Search view expected to appear after new search event trigger")
3787 };
3788
3789 cx.spawn(|mut cx| async move {
3790 window
3791 .update(&mut cx, |_, window, cx| {
3792 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3793 })
3794 .unwrap();
3795 })
3796 .detach();
3797 cx.background_executor.run_until_parked();
3798
3799 window.update(cx, |_, window, cx| {
3800 search_view.update(cx, |search_view, cx| {
3801 assert!(
3802 search_view.query_editor.focus_handle(cx).is_focused(window),
3803 "Empty search view should be focused after the toggle focus event: no results panel to focus on",
3804 );
3805 });
3806 }).unwrap();
3807
3808 window
3809 .update(cx, |_, window, cx| {
3810 search_view.update(cx, |search_view, cx| {
3811 let query_editor = &search_view.query_editor;
3812 assert!(
3813 query_editor.focus_handle(cx).is_focused(window),
3814 "Search view should be focused after the new search view is activated",
3815 );
3816 let query_text = query_editor.read(cx).text(cx);
3817 assert!(
3818 query_text.is_empty(),
3819 "New search query should be empty but got '{query_text}'",
3820 );
3821 let results_text = search_view
3822 .results_editor
3823 .update(cx, |editor, cx| editor.display_text(cx));
3824 assert!(
3825 results_text.is_empty(),
3826 "Empty search view should have no results but got '{results_text}'"
3827 );
3828 });
3829 })
3830 .unwrap();
3831
3832 window
3833 .update(cx, |_, window, cx| {
3834 search_view.update(cx, |search_view, cx| {
3835 search_view.query_editor.update(cx, |query_editor, cx| {
3836 query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", window, cx)
3837 });
3838 search_view.search(false, cx);
3839 });
3840 })
3841 .unwrap();
3842
3843 cx.background_executor.run_until_parked();
3844 window
3845 .update(cx, |_, window, cx| {
3846 search_view.update(cx, |search_view, cx| {
3847 let results_text = search_view
3848 .results_editor
3849 .update(cx, |editor, cx| editor.display_text(cx));
3850 assert!(
3851 results_text.is_empty(),
3852 "Search view for mismatching query should have no results but got '{results_text}'"
3853 );
3854 assert!(
3855 search_view.query_editor.focus_handle(cx).is_focused(window),
3856 "Search view should be focused after mismatching query had been used in search",
3857 );
3858 });
3859 })
3860 .unwrap();
3861 cx.spawn(|mut cx| async move {
3862 window.update(&mut cx, |_, window, cx| {
3863 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3864 })
3865 })
3866 .detach();
3867 cx.background_executor.run_until_parked();
3868 window.update(cx, |_, window, cx| {
3869 search_view.update(cx, |search_view, cx| {
3870 assert!(
3871 search_view.query_editor.focus_handle(cx).is_focused(window),
3872 "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
3873 );
3874 });
3875 }).unwrap();
3876
3877 window
3878 .update(cx, |_, window, cx| {
3879 search_view.update(cx, |search_view, cx| {
3880 search_view.query_editor.update(cx, |query_editor, cx| {
3881 query_editor.set_text("TWO", window, cx)
3882 });
3883 search_view.search(false, cx);
3884 })
3885 })
3886 .unwrap();
3887 cx.background_executor.run_until_parked();
3888 window.update(cx, |_, window, cx|
3889 search_view.update(cx, |search_view, cx| {
3890 assert_eq!(
3891 search_view
3892 .results_editor
3893 .update(cx, |editor, cx| editor.display_text(cx)),
3894 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
3895 "Search view results should match the query"
3896 );
3897 assert!(
3898 search_view.results_editor.focus_handle(cx).is_focused(window),
3899 "Search view with mismatching query should be focused after search results are available",
3900 );
3901 })).unwrap();
3902 cx.spawn(|mut cx| async move {
3903 window
3904 .update(&mut cx, |_, window, cx| {
3905 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3906 })
3907 .unwrap();
3908 })
3909 .detach();
3910 cx.background_executor.run_until_parked();
3911 window.update(cx, |_, window, cx| {
3912 search_view.update(cx, |search_view, cx| {
3913 assert!(
3914 search_view.results_editor.focus_handle(cx).is_focused(window),
3915 "Search view with matching query should still have its results editor focused after the toggle focus event",
3916 );
3917 });
3918 }).unwrap();
3919
3920 workspace.update_in(cx, |workspace, window, cx| {
3921 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3922 });
3923 cx.background_executor.run_until_parked();
3924 let Some(search_view_2) = cx.read(|cx| {
3925 workspace
3926 .read(cx)
3927 .active_pane()
3928 .read(cx)
3929 .active_item()
3930 .and_then(|item| item.downcast::<ProjectSearchView>())
3931 }) else {
3932 panic!("Search view expected to appear after new search event trigger")
3933 };
3934 assert!(
3935 search_view_2 != search_view,
3936 "New search view should be open after `workspace::NewSearch` event"
3937 );
3938
3939 window.update(cx, |_, window, cx| {
3940 search_view.update(cx, |search_view, cx| {
3941 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO", "First search view should not have an updated query");
3942 assert_eq!(
3943 search_view
3944 .results_editor
3945 .update(cx, |editor, cx| editor.display_text(cx)),
3946 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
3947 "Results of the first search view should not update too"
3948 );
3949 assert!(
3950 !search_view.query_editor.focus_handle(cx).is_focused(window),
3951 "Focus should be moved away from the first search view"
3952 );
3953 });
3954 }).unwrap();
3955
3956 window.update(cx, |_, window, cx| {
3957 search_view_2.update(cx, |search_view_2, cx| {
3958 assert_eq!(
3959 search_view_2.query_editor.read(cx).text(cx),
3960 "two",
3961 "New search view should get the query from the text cursor was at during the event spawn (first search view's first result)"
3962 );
3963 assert_eq!(
3964 search_view_2
3965 .results_editor
3966 .update(cx, |editor, cx| editor.display_text(cx)),
3967 "",
3968 "No search results should be in the 2nd view yet, as we did not spawn a search for it"
3969 );
3970 assert!(
3971 search_view_2.query_editor.focus_handle(cx).is_focused(window),
3972 "Focus should be moved into query editor of the new window"
3973 );
3974 });
3975 }).unwrap();
3976
3977 window
3978 .update(cx, |_, window, cx| {
3979 search_view_2.update(cx, |search_view_2, cx| {
3980 search_view_2.query_editor.update(cx, |query_editor, cx| {
3981 query_editor.set_text("FOUR", window, cx)
3982 });
3983 search_view_2.search(false, cx);
3984 });
3985 })
3986 .unwrap();
3987
3988 cx.background_executor.run_until_parked();
3989 window.update(cx, |_, window, cx| {
3990 search_view_2.update(cx, |search_view_2, cx| {
3991 assert_eq!(
3992 search_view_2
3993 .results_editor
3994 .update(cx, |editor, cx| editor.display_text(cx)),
3995 "\n\nconst FOUR: usize = one::ONE + three::THREE;",
3996 "New search view with the updated query should have new search results"
3997 );
3998 assert!(
3999 search_view_2.results_editor.focus_handle(cx).is_focused(window),
4000 "Search view with mismatching query should be focused after search results are available",
4001 );
4002 });
4003 }).unwrap();
4004
4005 cx.spawn(|mut cx| async move {
4006 window
4007 .update(&mut cx, |_, window, cx| {
4008 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
4009 })
4010 .unwrap();
4011 })
4012 .detach();
4013 cx.background_executor.run_until_parked();
4014 window.update(cx, |_, window, cx| {
4015 search_view_2.update(cx, |search_view_2, cx| {
4016 assert!(
4017 search_view_2.results_editor.focus_handle(cx).is_focused(window),
4018 "Search view with matching query should switch focus to the results editor after the toggle focus event",
4019 );
4020 });}).unwrap();
4021 }
4022
4023 #[perf]
4024 #[gpui::test]
4025 async fn test_new_project_search_in_directory(cx: &mut TestAppContext) {
4026 init_test(cx);
4027
4028 let fs = FakeFs::new(cx.background_executor.clone());
4029 fs.insert_tree(
4030 path!("/dir"),
4031 json!({
4032 "a": {
4033 "one.rs": "const ONE: usize = 1;",
4034 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
4035 },
4036 "b": {
4037 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
4038 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
4039 },
4040 }),
4041 )
4042 .await;
4043 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
4044 let worktree_id = project.read_with(cx, |project, cx| {
4045 project.worktrees(cx).next().unwrap().read(cx).id()
4046 });
4047 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
4048 let workspace = window
4049 .read_with(cx, |mw, _| mw.workspace().clone())
4050 .unwrap();
4051 let cx = &mut VisualTestContext::from_window(window.into(), cx);
4052 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
4053
4054 let active_item = cx.read(|cx| {
4055 workspace
4056 .read(cx)
4057 .active_pane()
4058 .read(cx)
4059 .active_item()
4060 .and_then(|item| item.downcast::<ProjectSearchView>())
4061 });
4062 assert!(
4063 active_item.is_none(),
4064 "Expected no search panel to be active"
4065 );
4066
4067 workspace.update_in(cx, move |workspace, window, cx| {
4068 assert_eq!(workspace.panes().len(), 1);
4069 workspace.panes()[0].update(cx, move |pane, cx| {
4070 pane.toolbar()
4071 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
4072 });
4073 });
4074
4075 let a_dir_entry = cx.update(|_, cx| {
4076 workspace
4077 .read(cx)
4078 .project()
4079 .read(cx)
4080 .entry_for_path(&(worktree_id, rel_path("a")).into(), cx)
4081 .expect("no entry for /a/ directory")
4082 .clone()
4083 });
4084 assert!(a_dir_entry.is_dir());
4085 workspace.update_in(cx, |workspace, window, cx| {
4086 ProjectSearchView::new_search_in_directory(workspace, &a_dir_entry.path, window, cx)
4087 });
4088
4089 let Some(search_view) = cx.read(|cx| {
4090 workspace
4091 .read(cx)
4092 .active_pane()
4093 .read(cx)
4094 .active_item()
4095 .and_then(|item| item.downcast::<ProjectSearchView>())
4096 }) else {
4097 panic!("Search view expected to appear after new search in directory event trigger")
4098 };
4099 cx.background_executor.run_until_parked();
4100 window
4101 .update(cx, |_, window, cx| {
4102 search_view.update(cx, |search_view, cx| {
4103 assert!(
4104 search_view.query_editor.focus_handle(cx).is_focused(window),
4105 "On new search in directory, focus should be moved into query editor"
4106 );
4107 search_view.excluded_files_editor.update(cx, |editor, cx| {
4108 assert!(
4109 editor.display_text(cx).is_empty(),
4110 "New search in directory should not have any excluded files"
4111 );
4112 });
4113 search_view.included_files_editor.update(cx, |editor, cx| {
4114 assert_eq!(
4115 editor.display_text(cx),
4116 a_dir_entry.path.display(PathStyle::local()),
4117 "New search in directory should have included dir entry path"
4118 );
4119 });
4120 });
4121 })
4122 .unwrap();
4123 window
4124 .update(cx, |_, window, cx| {
4125 search_view.update(cx, |search_view, cx| {
4126 search_view.query_editor.update(cx, |query_editor, cx| {
4127 query_editor.set_text("const", window, cx)
4128 });
4129 search_view.search(false, cx);
4130 });
4131 })
4132 .unwrap();
4133 cx.background_executor.run_until_parked();
4134 window
4135 .update(cx, |_, _, cx| {
4136 search_view.update(cx, |search_view, cx| {
4137 assert_eq!(
4138 search_view
4139 .results_editor
4140 .update(cx, |editor, cx| editor.display_text(cx)),
4141 "\n\nconst ONE: usize = 1;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
4142 "New search in directory should have a filter that matches a certain directory"
4143 );
4144 })
4145 })
4146 .unwrap();
4147 }
4148
4149 #[perf]
4150 #[gpui::test]
4151 async fn test_search_query_history(cx: &mut TestAppContext) {
4152 init_test(cx);
4153
4154 let fs = FakeFs::new(cx.background_executor.clone());
4155 fs.insert_tree(
4156 path!("/dir"),
4157 json!({
4158 "one.rs": "const ONE: usize = 1;",
4159 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
4160 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
4161 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
4162 }),
4163 )
4164 .await;
4165 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4166 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
4167 let workspace = window
4168 .read_with(cx, |mw, _| mw.workspace().clone())
4169 .unwrap();
4170 let cx = &mut VisualTestContext::from_window(window.into(), cx);
4171 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
4172
4173 workspace.update_in(cx, {
4174 let search_bar = search_bar.clone();
4175 |workspace, window, cx| {
4176 assert_eq!(workspace.panes().len(), 1);
4177 workspace.panes()[0].update(cx, |pane, cx| {
4178 pane.toolbar()
4179 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
4180 });
4181
4182 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
4183 }
4184 });
4185
4186 let search_view = cx.read(|cx| {
4187 workspace
4188 .read(cx)
4189 .active_pane()
4190 .read(cx)
4191 .active_item()
4192 .and_then(|item| item.downcast::<ProjectSearchView>())
4193 .expect("Search view expected to appear after new search event trigger")
4194 });
4195
4196 // Add 3 search items into the history + another unsubmitted one.
4197 window
4198 .update(cx, |_, window, cx| {
4199 search_view.update(cx, |search_view, cx| {
4200 search_view.search_options = SearchOptions::CASE_SENSITIVE;
4201 search_view.query_editor.update(cx, |query_editor, cx| {
4202 query_editor.set_text("ONE", window, cx)
4203 });
4204 search_view.search(false, cx);
4205 });
4206 })
4207 .unwrap();
4208
4209 cx.background_executor.run_until_parked();
4210 window
4211 .update(cx, |_, window, cx| {
4212 search_view.update(cx, |search_view, cx| {
4213 search_view.query_editor.update(cx, |query_editor, cx| {
4214 query_editor.set_text("TWO", window, cx)
4215 });
4216 search_view.search(false, cx);
4217 });
4218 })
4219 .unwrap();
4220 cx.background_executor.run_until_parked();
4221 window
4222 .update(cx, |_, window, cx| {
4223 search_view.update(cx, |search_view, cx| {
4224 search_view.query_editor.update(cx, |query_editor, cx| {
4225 query_editor.set_text("THREE", window, cx)
4226 });
4227 search_view.search(false, cx);
4228 })
4229 })
4230 .unwrap();
4231 cx.background_executor.run_until_parked();
4232 window
4233 .update(cx, |_, window, cx| {
4234 search_view.update(cx, |search_view, cx| {
4235 search_view.query_editor.update(cx, |query_editor, cx| {
4236 query_editor.set_text("JUST_TEXT_INPUT", window, cx)
4237 });
4238 })
4239 })
4240 .unwrap();
4241 cx.background_executor.run_until_parked();
4242
4243 // Ensure that the latest input with search settings is active.
4244 window
4245 .update(cx, |_, _, cx| {
4246 search_view.update(cx, |search_view, cx| {
4247 assert_eq!(
4248 search_view.query_editor.read(cx).text(cx),
4249 "JUST_TEXT_INPUT"
4250 );
4251 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4252 });
4253 })
4254 .unwrap();
4255
4256 // Next history query after the latest should preserve the current query.
4257 window
4258 .update(cx, |_, window, cx| {
4259 search_bar.update(cx, |search_bar, cx| {
4260 search_bar.focus_search(window, cx);
4261 search_bar.next_history_query(&NextHistoryQuery, window, cx);
4262 })
4263 })
4264 .unwrap();
4265 window
4266 .update(cx, |_, _, cx| {
4267 search_view.update(cx, |search_view, cx| {
4268 assert_eq!(
4269 search_view.query_editor.read(cx).text(cx),
4270 "JUST_TEXT_INPUT"
4271 );
4272 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4273 });
4274 })
4275 .unwrap();
4276 window
4277 .update(cx, |_, window, cx| {
4278 search_bar.update(cx, |search_bar, cx| {
4279 search_bar.focus_search(window, cx);
4280 search_bar.next_history_query(&NextHistoryQuery, window, cx);
4281 })
4282 })
4283 .unwrap();
4284 window
4285 .update(cx, |_, _, cx| {
4286 search_view.update(cx, |search_view, cx| {
4287 assert_eq!(
4288 search_view.query_editor.read(cx).text(cx),
4289 "JUST_TEXT_INPUT"
4290 );
4291 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4292 });
4293 })
4294 .unwrap();
4295
4296 // Previous query should navigate backwards through history.
4297 window
4298 .update(cx, |_, window, cx| {
4299 search_bar.update(cx, |search_bar, cx| {
4300 search_bar.focus_search(window, cx);
4301 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
4302 });
4303 })
4304 .unwrap();
4305 window
4306 .update(cx, |_, _, cx| {
4307 search_view.update(cx, |search_view, cx| {
4308 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
4309 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4310 });
4311 })
4312 .unwrap();
4313
4314 // Further previous items should go over the history in reverse order.
4315 window
4316 .update(cx, |_, window, cx| {
4317 search_bar.update(cx, |search_bar, cx| {
4318 search_bar.focus_search(window, cx);
4319 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
4320 });
4321 })
4322 .unwrap();
4323 window
4324 .update(cx, |_, _, cx| {
4325 search_view.update(cx, |search_view, cx| {
4326 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
4327 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4328 });
4329 })
4330 .unwrap();
4331
4332 // Previous items should never go behind the first history item.
4333 window
4334 .update(cx, |_, window, cx| {
4335 search_bar.update(cx, |search_bar, cx| {
4336 search_bar.focus_search(window, cx);
4337 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
4338 });
4339 })
4340 .unwrap();
4341 window
4342 .update(cx, |_, _, cx| {
4343 search_view.update(cx, |search_view, cx| {
4344 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
4345 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4346 });
4347 })
4348 .unwrap();
4349 window
4350 .update(cx, |_, window, cx| {
4351 search_bar.update(cx, |search_bar, cx| {
4352 search_bar.focus_search(window, cx);
4353 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
4354 });
4355 })
4356 .unwrap();
4357 window
4358 .update(cx, |_, _, cx| {
4359 search_view.update(cx, |search_view, cx| {
4360 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
4361 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4362 });
4363 })
4364 .unwrap();
4365
4366 // Next items should go over the history in the original order.
4367 window
4368 .update(cx, |_, window, cx| {
4369 search_bar.update(cx, |search_bar, cx| {
4370 search_bar.focus_search(window, cx);
4371 search_bar.next_history_query(&NextHistoryQuery, window, cx);
4372 });
4373 })
4374 .unwrap();
4375 window
4376 .update(cx, |_, _, cx| {
4377 search_view.update(cx, |search_view, cx| {
4378 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
4379 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4380 });
4381 })
4382 .unwrap();
4383
4384 window
4385 .update(cx, |_, window, cx| {
4386 search_view.update(cx, |search_view, cx| {
4387 search_view.query_editor.update(cx, |query_editor, cx| {
4388 query_editor.set_text("TWO_NEW", window, cx)
4389 });
4390 search_view.search(false, cx);
4391 });
4392 })
4393 .unwrap();
4394 cx.background_executor.run_until_parked();
4395 window
4396 .update(cx, |_, _, cx| {
4397 search_view.update(cx, |search_view, cx| {
4398 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
4399 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4400 });
4401 })
4402 .unwrap();
4403
4404 // New search input should add another entry to history and move the selection to the end of the history.
4405 window
4406 .update(cx, |_, window, cx| {
4407 search_bar.update(cx, |search_bar, cx| {
4408 search_bar.focus_search(window, cx);
4409 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
4410 });
4411 })
4412 .unwrap();
4413 window
4414 .update(cx, |_, _, cx| {
4415 search_view.update(cx, |search_view, cx| {
4416 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
4417 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4418 });
4419 })
4420 .unwrap();
4421 window
4422 .update(cx, |_, window, cx| {
4423 search_bar.update(cx, |search_bar, cx| {
4424 search_bar.focus_search(window, cx);
4425 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
4426 });
4427 })
4428 .unwrap();
4429 window
4430 .update(cx, |_, _, cx| {
4431 search_view.update(cx, |search_view, cx| {
4432 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
4433 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4434 });
4435 })
4436 .unwrap();
4437 window
4438 .update(cx, |_, window, cx| {
4439 search_bar.update(cx, |search_bar, cx| {
4440 search_bar.focus_search(window, cx);
4441 search_bar.next_history_query(&NextHistoryQuery, window, cx);
4442 });
4443 })
4444 .unwrap();
4445 window
4446 .update(cx, |_, _, cx| {
4447 search_view.update(cx, |search_view, cx| {
4448 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
4449 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4450 });
4451 })
4452 .unwrap();
4453 window
4454 .update(cx, |_, window, cx| {
4455 search_bar.update(cx, |search_bar, cx| {
4456 search_bar.focus_search(window, cx);
4457 search_bar.next_history_query(&NextHistoryQuery, window, cx);
4458 });
4459 })
4460 .unwrap();
4461 window
4462 .update(cx, |_, _, cx| {
4463 search_view.update(cx, |search_view, cx| {
4464 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
4465 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4466 });
4467 })
4468 .unwrap();
4469 window
4470 .update(cx, |_, window, cx| {
4471 search_bar.update(cx, |search_bar, cx| {
4472 search_bar.focus_search(window, cx);
4473 search_bar.next_history_query(&NextHistoryQuery, window, cx);
4474 });
4475 })
4476 .unwrap();
4477 window
4478 .update(cx, |_, _, cx| {
4479 search_view.update(cx, |search_view, cx| {
4480 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
4481 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4482 });
4483 })
4484 .unwrap();
4485
4486 // Typing text without running a search, then navigating history, should allow
4487 // restoring the draft when pressing next past the end.
4488 window
4489 .update(cx, |_, window, cx| {
4490 search_view.update(cx, |search_view, cx| {
4491 search_view.query_editor.update(cx, |query_editor, cx| {
4492 query_editor.set_text("unsaved draft", window, cx)
4493 });
4494 })
4495 })
4496 .unwrap();
4497 cx.background_executor.run_until_parked();
4498
4499 // Navigate up into history — the draft should be stashed.
4500 window
4501 .update(cx, |_, window, cx| {
4502 search_bar.update(cx, |search_bar, cx| {
4503 search_bar.focus_search(window, cx);
4504 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
4505 });
4506 })
4507 .unwrap();
4508 window
4509 .update(cx, |_, _, cx| {
4510 search_view.update(cx, |search_view, cx| {
4511 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
4512 });
4513 })
4514 .unwrap();
4515
4516 // Navigate forward through history.
4517 window
4518 .update(cx, |_, window, cx| {
4519 search_bar.update(cx, |search_bar, cx| {
4520 search_bar.focus_search(window, cx);
4521 search_bar.next_history_query(&NextHistoryQuery, window, cx);
4522 });
4523 })
4524 .unwrap();
4525 window
4526 .update(cx, |_, _, cx| {
4527 search_view.update(cx, |search_view, cx| {
4528 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
4529 });
4530 })
4531 .unwrap();
4532
4533 // Navigate past the end — the draft should be restored.
4534 window
4535 .update(cx, |_, window, cx| {
4536 search_bar.update(cx, |search_bar, cx| {
4537 search_bar.focus_search(window, cx);
4538 search_bar.next_history_query(&NextHistoryQuery, window, cx);
4539 });
4540 })
4541 .unwrap();
4542 window
4543 .update(cx, |_, _, cx| {
4544 search_view.update(cx, |search_view, cx| {
4545 assert_eq!(search_view.query_editor.read(cx).text(cx), "unsaved draft");
4546 });
4547 })
4548 .unwrap();
4549 }
4550
4551 #[perf]
4552 #[gpui::test]
4553 async fn test_search_query_history_with_multiple_views(cx: &mut TestAppContext) {
4554 init_test(cx);
4555
4556 let fs = FakeFs::new(cx.background_executor.clone());
4557 fs.insert_tree(
4558 path!("/dir"),
4559 json!({
4560 "one.rs": "const ONE: usize = 1;",
4561 }),
4562 )
4563 .await;
4564 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4565 let worktree_id = project.update(cx, |this, cx| {
4566 this.worktrees(cx).next().unwrap().read(cx).id()
4567 });
4568
4569 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
4570 let workspace = window
4571 .read_with(cx, |mw, _| mw.workspace().clone())
4572 .unwrap();
4573 let cx = &mut VisualTestContext::from_window(window.into(), cx);
4574
4575 let panes: Vec<_> = workspace.update_in(cx, |this, _, _| this.panes().to_owned());
4576
4577 let search_bar_1 = window.build_entity(cx, |_, _| ProjectSearchBar::new());
4578 let search_bar_2 = window.build_entity(cx, |_, _| ProjectSearchBar::new());
4579
4580 assert_eq!(panes.len(), 1);
4581 let first_pane = panes.first().cloned().unwrap();
4582 assert_eq!(cx.update(|_, cx| first_pane.read(cx).items_len()), 0);
4583 workspace
4584 .update_in(cx, |workspace, window, cx| {
4585 workspace.open_path(
4586 (worktree_id, rel_path("one.rs")),
4587 Some(first_pane.downgrade()),
4588 true,
4589 window,
4590 cx,
4591 )
4592 })
4593 .await
4594 .unwrap();
4595 assert_eq!(cx.update(|_, cx| first_pane.read(cx).items_len()), 1);
4596
4597 // Add a project search item to the first pane
4598 workspace.update_in(cx, {
4599 let search_bar = search_bar_1.clone();
4600 |workspace, window, cx| {
4601 first_pane.update(cx, |pane, cx| {
4602 pane.toolbar()
4603 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
4604 });
4605
4606 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
4607 }
4608 });
4609 let search_view_1 = cx.read(|cx| {
4610 workspace
4611 .read(cx)
4612 .active_item(cx)
4613 .and_then(|item| item.downcast::<ProjectSearchView>())
4614 .expect("Search view expected to appear after new search event trigger")
4615 });
4616
4617 let second_pane = workspace
4618 .update_in(cx, |workspace, window, cx| {
4619 workspace.split_and_clone(
4620 first_pane.clone(),
4621 workspace::SplitDirection::Right,
4622 window,
4623 cx,
4624 )
4625 })
4626 .await
4627 .unwrap();
4628 assert_eq!(cx.update(|_, cx| second_pane.read(cx).items_len()), 1);
4629
4630 assert_eq!(cx.update(|_, cx| second_pane.read(cx).items_len()), 1);
4631 assert_eq!(cx.update(|_, cx| first_pane.read(cx).items_len()), 2);
4632
4633 // Add a project search item to the second pane
4634 workspace.update_in(cx, {
4635 let search_bar = search_bar_2.clone();
4636 let pane = second_pane.clone();
4637 move |workspace, window, cx| {
4638 assert_eq!(workspace.panes().len(), 2);
4639 pane.update(cx, |pane, cx| {
4640 pane.toolbar()
4641 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
4642 });
4643
4644 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
4645 }
4646 });
4647
4648 let search_view_2 = cx.read(|cx| {
4649 workspace
4650 .read(cx)
4651 .active_item(cx)
4652 .and_then(|item| item.downcast::<ProjectSearchView>())
4653 .expect("Search view expected to appear after new search event trigger")
4654 });
4655
4656 cx.run_until_parked();
4657 assert_eq!(cx.update(|_, cx| first_pane.read(cx).items_len()), 2);
4658 assert_eq!(cx.update(|_, cx| second_pane.read(cx).items_len()), 2);
4659
4660 let update_search_view =
4661 |search_view: &Entity<ProjectSearchView>, query: &str, cx: &mut TestAppContext| {
4662 window
4663 .update(cx, |_, window, cx| {
4664 search_view.update(cx, |search_view, cx| {
4665 search_view.query_editor.update(cx, |query_editor, cx| {
4666 query_editor.set_text(query, window, cx)
4667 });
4668 search_view.search(false, cx);
4669 });
4670 })
4671 .unwrap();
4672 };
4673
4674 let active_query =
4675 |search_view: &Entity<ProjectSearchView>, cx: &mut TestAppContext| -> String {
4676 window
4677 .update(cx, |_, _, cx| {
4678 search_view.update(cx, |search_view, cx| {
4679 search_view.query_editor.read(cx).text(cx)
4680 })
4681 })
4682 .unwrap()
4683 };
4684
4685 let select_prev_history_item =
4686 |search_bar: &Entity<ProjectSearchBar>, cx: &mut TestAppContext| {
4687 window
4688 .update(cx, |_, window, cx| {
4689 search_bar.update(cx, |search_bar, cx| {
4690 search_bar.focus_search(window, cx);
4691 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
4692 })
4693 })
4694 .unwrap();
4695 };
4696
4697 let select_next_history_item =
4698 |search_bar: &Entity<ProjectSearchBar>, cx: &mut TestAppContext| {
4699 window
4700 .update(cx, |_, window, cx| {
4701 search_bar.update(cx, |search_bar, cx| {
4702 search_bar.focus_search(window, cx);
4703 search_bar.next_history_query(&NextHistoryQuery, window, cx);
4704 })
4705 })
4706 .unwrap();
4707 };
4708
4709 update_search_view(&search_view_1, "ONE", cx);
4710 cx.background_executor.run_until_parked();
4711
4712 update_search_view(&search_view_2, "TWO", cx);
4713 cx.background_executor.run_until_parked();
4714
4715 assert_eq!(active_query(&search_view_1, cx), "ONE");
4716 assert_eq!(active_query(&search_view_2, cx), "TWO");
4717
4718 // Selecting previous history item should select the query from search view 1.
4719 select_prev_history_item(&search_bar_2, cx);
4720 assert_eq!(active_query(&search_view_2, cx), "ONE");
4721
4722 // Selecting the previous history item should not change the query as it is already the first item.
4723 select_prev_history_item(&search_bar_2, cx);
4724 assert_eq!(active_query(&search_view_2, cx), "ONE");
4725
4726 // Changing the query in search view 2 should not affect the history of search view 1.
4727 assert_eq!(active_query(&search_view_1, cx), "ONE");
4728
4729 // Deploying a new search in search view 2
4730 update_search_view(&search_view_2, "THREE", cx);
4731 cx.background_executor.run_until_parked();
4732
4733 select_next_history_item(&search_bar_2, cx);
4734 assert_eq!(active_query(&search_view_2, cx), "THREE");
4735
4736 select_prev_history_item(&search_bar_2, cx);
4737 assert_eq!(active_query(&search_view_2, cx), "TWO");
4738
4739 select_prev_history_item(&search_bar_2, cx);
4740 assert_eq!(active_query(&search_view_2, cx), "ONE");
4741
4742 select_prev_history_item(&search_bar_2, cx);
4743 assert_eq!(active_query(&search_view_2, cx), "ONE");
4744
4745 select_prev_history_item(&search_bar_2, cx);
4746 assert_eq!(active_query(&search_view_2, cx), "ONE");
4747
4748 // Search view 1 should now see the query from search view 2.
4749 assert_eq!(active_query(&search_view_1, cx), "ONE");
4750
4751 select_next_history_item(&search_bar_2, cx);
4752 assert_eq!(active_query(&search_view_2, cx), "TWO");
4753
4754 // Here is the new query from search view 2
4755 select_next_history_item(&search_bar_2, cx);
4756 assert_eq!(active_query(&search_view_2, cx), "THREE");
4757
4758 select_next_history_item(&search_bar_2, cx);
4759 assert_eq!(active_query(&search_view_2, cx), "THREE");
4760
4761 select_next_history_item(&search_bar_1, cx);
4762 assert_eq!(active_query(&search_view_1, cx), "TWO");
4763
4764 select_next_history_item(&search_bar_1, cx);
4765 assert_eq!(active_query(&search_view_1, cx), "THREE");
4766
4767 select_next_history_item(&search_bar_1, cx);
4768 assert_eq!(active_query(&search_view_1, cx), "THREE");
4769 }
4770
4771 #[perf]
4772 #[gpui::test]
4773 async fn test_deploy_search_with_multiple_panes(cx: &mut TestAppContext) {
4774 init_test(cx);
4775
4776 // Setup 2 panes, both with a file open and one with a project search.
4777 let fs = FakeFs::new(cx.background_executor.clone());
4778 fs.insert_tree(
4779 path!("/dir"),
4780 json!({
4781 "one.rs": "const ONE: usize = 1;",
4782 }),
4783 )
4784 .await;
4785 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4786 let worktree_id = project.update(cx, |this, cx| {
4787 this.worktrees(cx).next().unwrap().read(cx).id()
4788 });
4789 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
4790 let workspace = window
4791 .read_with(cx, |mw, _| mw.workspace().clone())
4792 .unwrap();
4793 let cx = &mut VisualTestContext::from_window(window.into(), cx);
4794 let panes: Vec<_> = workspace.update_in(cx, |this, _, _| this.panes().to_owned());
4795 assert_eq!(panes.len(), 1);
4796 let first_pane = panes.first().cloned().unwrap();
4797 assert_eq!(cx.update(|_, cx| first_pane.read(cx).items_len()), 0);
4798 workspace
4799 .update_in(cx, |workspace, window, cx| {
4800 workspace.open_path(
4801 (worktree_id, rel_path("one.rs")),
4802 Some(first_pane.downgrade()),
4803 true,
4804 window,
4805 cx,
4806 )
4807 })
4808 .await
4809 .unwrap();
4810 assert_eq!(cx.update(|_, cx| first_pane.read(cx).items_len()), 1);
4811 let second_pane = workspace
4812 .update_in(cx, |workspace, window, cx| {
4813 workspace.split_and_clone(
4814 first_pane.clone(),
4815 workspace::SplitDirection::Right,
4816 window,
4817 cx,
4818 )
4819 })
4820 .await
4821 .unwrap();
4822 assert_eq!(cx.update(|_, cx| second_pane.read(cx).items_len()), 1);
4823 assert!(
4824 window
4825 .update(cx, |_, window, cx| second_pane
4826 .focus_handle(cx)
4827 .contains_focused(window, cx))
4828 .unwrap()
4829 );
4830 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
4831 workspace.update_in(cx, {
4832 let search_bar = search_bar.clone();
4833 let pane = first_pane.clone();
4834 move |workspace, window, cx| {
4835 assert_eq!(workspace.panes().len(), 2);
4836 pane.update(cx, move |pane, cx| {
4837 pane.toolbar()
4838 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
4839 });
4840 }
4841 });
4842
4843 // Add a project search item to the second pane
4844 workspace.update_in(cx, {
4845 |workspace, window, cx| {
4846 assert_eq!(workspace.panes().len(), 2);
4847 second_pane.update(cx, |pane, cx| {
4848 pane.toolbar()
4849 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
4850 });
4851
4852 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
4853 }
4854 });
4855
4856 cx.run_until_parked();
4857 assert_eq!(cx.update(|_, cx| second_pane.read(cx).items_len()), 2);
4858 assert_eq!(cx.update(|_, cx| first_pane.read(cx).items_len()), 1);
4859
4860 // Focus the first pane
4861 workspace.update_in(cx, |workspace, window, cx| {
4862 assert_eq!(workspace.active_pane(), &second_pane);
4863 second_pane.update(cx, |this, cx| {
4864 assert_eq!(this.active_item_index(), 1);
4865 this.activate_previous_item(&Default::default(), window, cx);
4866 assert_eq!(this.active_item_index(), 0);
4867 });
4868 workspace.activate_pane_in_direction(workspace::SplitDirection::Left, window, cx);
4869 });
4870 workspace.update_in(cx, |workspace, _, cx| {
4871 assert_eq!(workspace.active_pane(), &first_pane);
4872 assert_eq!(first_pane.read(cx).items_len(), 1);
4873 assert_eq!(second_pane.read(cx).items_len(), 2);
4874 });
4875
4876 // Deploy a new search
4877 cx.dispatch_action(DeploySearch::default());
4878
4879 // Both panes should now have a project search in them
4880 workspace.update_in(cx, |workspace, window, cx| {
4881 assert_eq!(workspace.active_pane(), &first_pane);
4882 first_pane.read_with(cx, |this, _| {
4883 assert_eq!(this.active_item_index(), 1);
4884 assert_eq!(this.items_len(), 2);
4885 });
4886 second_pane.update(cx, |this, cx| {
4887 assert!(!cx.focus_handle().contains_focused(window, cx));
4888 assert_eq!(this.items_len(), 2);
4889 });
4890 });
4891
4892 // Focus the second pane's non-search item
4893 window
4894 .update(cx, |_workspace, window, cx| {
4895 second_pane.update(cx, |pane, cx| {
4896 pane.activate_next_item(&Default::default(), window, cx)
4897 });
4898 })
4899 .unwrap();
4900
4901 // Deploy a new search
4902 cx.dispatch_action(DeploySearch::default());
4903
4904 // The project search view should now be focused in the second pane
4905 // And the number of items should be unchanged.
4906 window
4907 .update(cx, |_workspace, _, cx| {
4908 second_pane.update(cx, |pane, _cx| {
4909 assert!(
4910 pane.active_item()
4911 .unwrap()
4912 .downcast::<ProjectSearchView>()
4913 .is_some()
4914 );
4915
4916 assert_eq!(pane.items_len(), 2);
4917 });
4918 })
4919 .unwrap();
4920 }
4921
4922 #[perf]
4923 #[gpui::test]
4924 async fn test_scroll_search_results_to_top(cx: &mut TestAppContext) {
4925 init_test(cx);
4926
4927 // We need many lines in the search results to be able to scroll the window
4928 let fs = FakeFs::new(cx.background_executor.clone());
4929 fs.insert_tree(
4930 path!("/dir"),
4931 json!({
4932 "1.txt": "\n\n\n\n\n A \n\n\n\n\n",
4933 "2.txt": "\n\n\n\n\n A \n\n\n\n\n",
4934 "3.rs": "\n\n\n\n\n A \n\n\n\n\n",
4935 "4.rs": "\n\n\n\n\n A \n\n\n\n\n",
4936 "5.rs": "\n\n\n\n\n A \n\n\n\n\n",
4937 "6.rs": "\n\n\n\n\n A \n\n\n\n\n",
4938 "7.rs": "\n\n\n\n\n A \n\n\n\n\n",
4939 "8.rs": "\n\n\n\n\n A \n\n\n\n\n",
4940 "9.rs": "\n\n\n\n\n A \n\n\n\n\n",
4941 "a.rs": "\n\n\n\n\n A \n\n\n\n\n",
4942 "b.rs": "\n\n\n\n\n B \n\n\n\n\n",
4943 "c.rs": "\n\n\n\n\n B \n\n\n\n\n",
4944 "d.rs": "\n\n\n\n\n B \n\n\n\n\n",
4945 "e.rs": "\n\n\n\n\n B \n\n\n\n\n",
4946 "f.rs": "\n\n\n\n\n B \n\n\n\n\n",
4947 "g.rs": "\n\n\n\n\n B \n\n\n\n\n",
4948 "h.rs": "\n\n\n\n\n B \n\n\n\n\n",
4949 "i.rs": "\n\n\n\n\n B \n\n\n\n\n",
4950 "j.rs": "\n\n\n\n\n B \n\n\n\n\n",
4951 "k.rs": "\n\n\n\n\n B \n\n\n\n\n",
4952 }),
4953 )
4954 .await;
4955 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4956 let window =
4957 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4958 let workspace = window
4959 .read_with(cx, |mw, _| mw.workspace().clone())
4960 .unwrap();
4961 let search = cx.new(|cx| ProjectSearch::new(project, cx));
4962 let search_view = cx.add_window(|window, cx| {
4963 ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
4964 });
4965
4966 // First search
4967 perform_search(search_view, "A", cx);
4968 search_view
4969 .update(cx, |search_view, window, cx| {
4970 search_view.results_editor.update(cx, |results_editor, cx| {
4971 // Results are correct and scrolled to the top
4972 assert_eq!(
4973 results_editor.display_text(cx).match_indices(" A ").count(),
4974 10
4975 );
4976 assert_eq!(results_editor.scroll_position(cx), Point::default());
4977
4978 // Scroll results all the way down
4979 results_editor.scroll(
4980 Point::new(0., f64::MAX),
4981 Some(Axis::Vertical),
4982 window,
4983 cx,
4984 );
4985 });
4986 })
4987 .expect("unable to update search view");
4988
4989 // Second search
4990 perform_search(search_view, "B", cx);
4991 search_view
4992 .update(cx, |search_view, _, cx| {
4993 search_view.results_editor.update(cx, |results_editor, cx| {
4994 // Results are correct...
4995 assert_eq!(
4996 results_editor.display_text(cx).match_indices(" B ").count(),
4997 10
4998 );
4999 // ...and scrolled back to the top
5000 assert_eq!(results_editor.scroll_position(cx), Point::default());
5001 });
5002 })
5003 .expect("unable to update search view");
5004 }
5005
5006 #[perf]
5007 #[gpui::test]
5008 async fn test_buffer_search_query_reused(cx: &mut TestAppContext) {
5009 init_test(cx);
5010
5011 let fs = FakeFs::new(cx.background_executor.clone());
5012 fs.insert_tree(
5013 path!("/dir"),
5014 json!({
5015 "one.rs": "const ONE: usize = 1;",
5016 }),
5017 )
5018 .await;
5019 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
5020 let worktree_id = project.update(cx, |this, cx| {
5021 this.worktrees(cx).next().unwrap().read(cx).id()
5022 });
5023 let window =
5024 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5025 let workspace = window
5026 .read_with(cx, |mw, _| mw.workspace().clone())
5027 .unwrap();
5028 let mut cx = VisualTestContext::from_window(window.into(), cx);
5029
5030 let editor = workspace
5031 .update_in(&mut cx, |workspace, window, cx| {
5032 workspace.open_path((worktree_id, rel_path("one.rs")), None, true, window, cx)
5033 })
5034 .await
5035 .unwrap()
5036 .downcast::<Editor>()
5037 .unwrap();
5038
5039 // Wait for the unstaged changes to be loaded
5040 cx.run_until_parked();
5041
5042 let buffer_search_bar = cx.new_window_entity(|window, cx| {
5043 let mut search_bar =
5044 BufferSearchBar::new(Some(project.read(cx).languages().clone()), window, cx);
5045 search_bar.set_active_pane_item(Some(&editor), window, cx);
5046 search_bar.show(window, cx);
5047 search_bar
5048 });
5049
5050 let panes: Vec<_> = workspace.update_in(&mut cx, |this, _, _| this.panes().to_owned());
5051 assert_eq!(panes.len(), 1);
5052 let pane = panes.first().cloned().unwrap();
5053 pane.update_in(&mut cx, |pane, window, cx| {
5054 pane.toolbar().update(cx, |toolbar, cx| {
5055 toolbar.add_item(buffer_search_bar.clone(), window, cx);
5056 })
5057 });
5058
5059 let buffer_search_query = "search bar query";
5060 buffer_search_bar
5061 .update_in(&mut cx, |buffer_search_bar, window, cx| {
5062 buffer_search_bar.focus_handle(cx).focus(window, cx);
5063 buffer_search_bar.search(buffer_search_query, None, true, window, cx)
5064 })
5065 .await
5066 .unwrap();
5067
5068 workspace.update_in(&mut cx, |workspace, window, cx| {
5069 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
5070 });
5071 cx.run_until_parked();
5072 let project_search_view = pane
5073 .read_with(&cx, |pane, _| {
5074 pane.active_item()
5075 .and_then(|item| item.downcast::<ProjectSearchView>())
5076 })
5077 .expect("should open a project search view after spawning a new search");
5078 project_search_view.update(&mut cx, |search_view, cx| {
5079 assert_eq!(
5080 search_view.search_query_text(cx),
5081 buffer_search_query,
5082 "Project search should take the query from the buffer search bar since it got focused and had a query inside"
5083 );
5084 });
5085 }
5086
5087 #[gpui::test]
5088 async fn test_search_dismisses_modal(cx: &mut TestAppContext) {
5089 init_test(cx);
5090
5091 let fs = FakeFs::new(cx.background_executor.clone());
5092 fs.insert_tree(
5093 path!("/dir"),
5094 json!({
5095 "one.rs": "const ONE: usize = 1;",
5096 }),
5097 )
5098 .await;
5099 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
5100 let window =
5101 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5102 let workspace = window
5103 .read_with(cx, |mw, _| mw.workspace().clone())
5104 .unwrap();
5105 let cx = &mut VisualTestContext::from_window(window.into(), cx);
5106
5107 struct EmptyModalView {
5108 focus_handle: gpui::FocusHandle,
5109 }
5110 impl EventEmitter<gpui::DismissEvent> for EmptyModalView {}
5111 impl Render for EmptyModalView {
5112 fn render(&mut self, _: &mut Window, _: &mut Context<'_, Self>) -> impl IntoElement {
5113 div()
5114 }
5115 }
5116 impl Focusable for EmptyModalView {
5117 fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle {
5118 self.focus_handle.clone()
5119 }
5120 }
5121 impl workspace::ModalView for EmptyModalView {}
5122
5123 workspace.update_in(cx, |workspace, window, cx| {
5124 workspace.toggle_modal(window, cx, |_, cx| EmptyModalView {
5125 focus_handle: cx.focus_handle(),
5126 });
5127 assert!(workspace.has_active_modal(window, cx));
5128 });
5129
5130 cx.dispatch_action(Deploy::find());
5131
5132 workspace.update_in(cx, |workspace, window, cx| {
5133 assert!(!workspace.has_active_modal(window, cx));
5134 workspace.toggle_modal(window, cx, |_, cx| EmptyModalView {
5135 focus_handle: cx.focus_handle(),
5136 });
5137 assert!(workspace.has_active_modal(window, cx));
5138 });
5139
5140 cx.dispatch_action(DeploySearch::default());
5141
5142 workspace.update_in(cx, |workspace, window, cx| {
5143 assert!(!workspace.has_active_modal(window, cx));
5144 });
5145 }
5146
5147 #[perf]
5148 #[gpui::test]
5149 async fn test_search_with_inlays(cx: &mut TestAppContext) {
5150 init_test(cx);
5151 cx.update(|cx| {
5152 SettingsStore::update_global(cx, |store, cx| {
5153 store.update_user_settings(cx, |settings| {
5154 settings.project.all_languages.defaults.inlay_hints =
5155 Some(InlayHintSettingsContent {
5156 enabled: Some(true),
5157 ..InlayHintSettingsContent::default()
5158 })
5159 });
5160 });
5161 });
5162
5163 let fs = FakeFs::new(cx.background_executor.clone());
5164 fs.insert_tree(
5165 path!("/dir"),
5166 // `\n` , a trailing line on the end, is important for the test case
5167 json!({
5168 "main.rs": "fn main() { let a = 2; }\n",
5169 }),
5170 )
5171 .await;
5172
5173 let requests_count = Arc::new(AtomicUsize::new(0));
5174 let closure_requests_count = requests_count.clone();
5175 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
5176 let language_registry = project.read_with(cx, |project, _| project.languages().clone());
5177 let language = rust_lang();
5178 language_registry.add(language);
5179 let mut fake_servers = language_registry.register_fake_lsp(
5180 "Rust",
5181 FakeLspAdapter {
5182 capabilities: lsp::ServerCapabilities {
5183 inlay_hint_provider: Some(lsp::OneOf::Left(true)),
5184 ..lsp::ServerCapabilities::default()
5185 },
5186 initializer: Some(Box::new(move |fake_server| {
5187 let requests_count = closure_requests_count.clone();
5188 fake_server.set_request_handler::<lsp::request::InlayHintRequest, _, _>({
5189 move |_, _| {
5190 let requests_count = requests_count.clone();
5191 async move {
5192 requests_count.fetch_add(1, atomic::Ordering::Release);
5193 Ok(Some(vec![lsp::InlayHint {
5194 position: lsp::Position::new(0, 17),
5195 label: lsp::InlayHintLabel::String(": i32".to_owned()),
5196 kind: Some(lsp::InlayHintKind::TYPE),
5197 text_edits: None,
5198 tooltip: None,
5199 padding_left: None,
5200 padding_right: None,
5201 data: None,
5202 }]))
5203 }
5204 }
5205 });
5206 })),
5207 ..FakeLspAdapter::default()
5208 },
5209 );
5210
5211 let window =
5212 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5213 let workspace = window
5214 .read_with(cx, |mw, _| mw.workspace().clone())
5215 .unwrap();
5216 let cx = &mut VisualTestContext::from_window(window.into(), cx);
5217 let search = cx.new(|cx| ProjectSearch::new(project.clone(), cx));
5218 let search_view = cx.add_window(|window, cx| {
5219 ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
5220 });
5221
5222 perform_search(search_view, "let ", cx);
5223 let fake_server = fake_servers.next().await.unwrap();
5224 cx.executor().advance_clock(Duration::from_secs(1));
5225 cx.executor().run_until_parked();
5226 search_view
5227 .update(cx, |search_view, _, cx| {
5228 assert_eq!(
5229 search_view
5230 .results_editor
5231 .update(cx, |editor, cx| editor.display_text(cx)),
5232 "\n\nfn main() { let a: i32 = 2; }\n"
5233 );
5234 })
5235 .unwrap();
5236 assert_eq!(
5237 requests_count.load(atomic::Ordering::Acquire),
5238 1,
5239 "New hints should have been queried",
5240 );
5241
5242 // Can do the 2nd search without any panics
5243 perform_search(search_view, "let ", cx);
5244 cx.executor().advance_clock(Duration::from_secs(1));
5245 cx.executor().run_until_parked();
5246 search_view
5247 .update(cx, |search_view, _, cx| {
5248 assert_eq!(
5249 search_view
5250 .results_editor
5251 .update(cx, |editor, cx| editor.display_text(cx)),
5252 "\n\nfn main() { let a: i32 = 2; }\n"
5253 );
5254 })
5255 .unwrap();
5256 assert_eq!(
5257 requests_count.load(atomic::Ordering::Acquire),
5258 2,
5259 "We did drop the previous buffer when cleared the old project search results, hence another query was made",
5260 );
5261
5262 let singleton_editor = workspace
5263 .update_in(cx, |workspace, window, cx| {
5264 workspace.open_abs_path(
5265 PathBuf::from(path!("/dir/main.rs")),
5266 workspace::OpenOptions::default(),
5267 window,
5268 cx,
5269 )
5270 })
5271 .await
5272 .unwrap()
5273 .downcast::<Editor>()
5274 .unwrap();
5275 cx.executor().advance_clock(Duration::from_millis(100));
5276 cx.executor().run_until_parked();
5277 singleton_editor.update(cx, |editor, cx| {
5278 assert_eq!(
5279 editor.display_text(cx),
5280 "fn main() { let a: i32 = 2; }\n",
5281 "Newly opened editor should have the correct text with hints",
5282 );
5283 });
5284 assert_eq!(
5285 requests_count.load(atomic::Ordering::Acquire),
5286 2,
5287 "Opening the same buffer again should reuse the cached hints",
5288 );
5289
5290 window
5291 .update(cx, |_, window, cx| {
5292 singleton_editor.update(cx, |editor, cx| {
5293 editor.handle_input("test", window, cx);
5294 });
5295 })
5296 .unwrap();
5297
5298 cx.executor().advance_clock(Duration::from_secs(1));
5299 cx.executor().run_until_parked();
5300 singleton_editor.update(cx, |editor, cx| {
5301 assert_eq!(
5302 editor.display_text(cx),
5303 "testfn main() { l: i32et a = 2; }\n",
5304 "Newly opened editor should have the correct text with hints",
5305 );
5306 });
5307 assert_eq!(
5308 requests_count.load(atomic::Ordering::Acquire),
5309 3,
5310 "We have edited the buffer and should send a new request",
5311 );
5312
5313 window
5314 .update(cx, |_, window, cx| {
5315 singleton_editor.update(cx, |editor, cx| {
5316 editor.undo(&editor::actions::Undo, window, cx);
5317 });
5318 })
5319 .unwrap();
5320 cx.executor().advance_clock(Duration::from_secs(1));
5321 cx.executor().run_until_parked();
5322 assert_eq!(
5323 requests_count.load(atomic::Ordering::Acquire),
5324 4,
5325 "We have edited the buffer again and should send a new request again",
5326 );
5327 singleton_editor.update(cx, |editor, cx| {
5328 assert_eq!(
5329 editor.display_text(cx),
5330 "fn main() { let a: i32 = 2; }\n",
5331 "Newly opened editor should have the correct text with hints",
5332 );
5333 });
5334 project.update(cx, |_, cx| {
5335 cx.emit(project::Event::RefreshInlayHints {
5336 server_id: fake_server.server.server_id(),
5337 request_id: Some(1),
5338 });
5339 });
5340 cx.executor().advance_clock(Duration::from_secs(1));
5341 cx.executor().run_until_parked();
5342 assert_eq!(
5343 requests_count.load(atomic::Ordering::Acquire),
5344 5,
5345 "After a simulated server refresh request, we should have sent another request",
5346 );
5347
5348 perform_search(search_view, "let ", cx);
5349 cx.executor().advance_clock(Duration::from_secs(1));
5350 cx.executor().run_until_parked();
5351 assert_eq!(
5352 requests_count.load(atomic::Ordering::Acquire),
5353 5,
5354 "New project search should reuse the cached hints",
5355 );
5356 search_view
5357 .update(cx, |search_view, _, cx| {
5358 assert_eq!(
5359 search_view
5360 .results_editor
5361 .update(cx, |editor, cx| editor.display_text(cx)),
5362 "\n\nfn main() { let a: i32 = 2; }\n"
5363 );
5364 })
5365 .unwrap();
5366 }
5367
5368 #[gpui::test]
5369 async fn test_deleted_file_removed_from_search_results(cx: &mut TestAppContext) {
5370 init_test(cx);
5371
5372 let fs = FakeFs::new(cx.background_executor.clone());
5373 fs.insert_tree(
5374 path!("/dir"),
5375 json!({
5376 "file_a.txt": "hello world",
5377 "file_b.txt": "hello universe",
5378 }),
5379 )
5380 .await;
5381
5382 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
5383 let window =
5384 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5385 let workspace = window
5386 .read_with(cx, |mw, _| mw.workspace().clone())
5387 .unwrap();
5388 let search = cx.new(|cx| ProjectSearch::new(project.clone(), cx));
5389 let search_view = cx.add_window(|window, cx| {
5390 ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
5391 });
5392
5393 perform_search(search_view, "hello", cx);
5394
5395 search_view
5396 .update(cx, |search_view, _window, cx| {
5397 let match_count = search_view.entity.read(cx).match_ranges.len();
5398 assert_eq!(match_count, 2, "Should have matches from both files");
5399 })
5400 .unwrap();
5401
5402 // Delete file_b.txt
5403 fs.remove_file(
5404 path!("/dir/file_b.txt").as_ref(),
5405 fs::RemoveOptions::default(),
5406 )
5407 .await
5408 .unwrap();
5409 cx.run_until_parked();
5410
5411 // Verify deleted file's results are removed proactively
5412 search_view
5413 .update(cx, |search_view, _window, cx| {
5414 let results_text = search_view
5415 .results_editor
5416 .update(cx, |editor, cx| editor.display_text(cx));
5417 assert!(
5418 !results_text.contains("universe"),
5419 "Deleted file's content should be removed from results, got: {results_text}"
5420 );
5421 assert!(
5422 results_text.contains("world"),
5423 "Remaining file's content should still be present, got: {results_text}"
5424 );
5425 })
5426 .unwrap();
5427
5428 // Re-run the search and verify deleted file stays gone
5429 perform_search(search_view, "hello", cx);
5430
5431 search_view
5432 .update(cx, |search_view, _window, cx| {
5433 let results_text = search_view
5434 .results_editor
5435 .update(cx, |editor, cx| editor.display_text(cx));
5436 assert!(
5437 !results_text.contains("universe"),
5438 "Deleted file should not reappear after re-search, got: {results_text}"
5439 );
5440 assert!(
5441 results_text.contains("world"),
5442 "Remaining file should still be found, got: {results_text}"
5443 );
5444 assert_eq!(
5445 search_view.entity.read(cx).match_ranges.len(),
5446 1,
5447 "Should only have match from the remaining file"
5448 );
5449 })
5450 .unwrap();
5451 }
5452
5453 #[gpui::test]
5454 async fn test_deploy_search_applies_and_resets_options(cx: &mut TestAppContext) {
5455 init_test(cx);
5456
5457 let fs = FakeFs::new(cx.background_executor.clone());
5458 fs.insert_tree(
5459 path!("/dir"),
5460 json!({
5461 "one.rs": "const ONE: usize = 1;",
5462 }),
5463 )
5464 .await;
5465 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
5466 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
5467 let workspace = window
5468 .read_with(cx, |mw, _| mw.workspace().clone())
5469 .unwrap();
5470 let cx = &mut VisualTestContext::from_window(window.into(), cx);
5471 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
5472
5473 workspace.update_in(cx, |workspace, window, cx| {
5474 workspace.panes()[0].update(cx, |pane, cx| {
5475 pane.toolbar()
5476 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
5477 });
5478
5479 ProjectSearchView::deploy_search(
5480 workspace,
5481 &workspace::DeploySearch {
5482 regex: Some(true),
5483 case_sensitive: Some(true),
5484 whole_word: Some(true),
5485 include_ignored: Some(true),
5486 query: Some("Test_Query".into()),
5487 ..Default::default()
5488 },
5489 window,
5490 cx,
5491 )
5492 });
5493
5494 let search_view = cx
5495 .read(|cx| {
5496 workspace
5497 .read(cx)
5498 .active_pane()
5499 .read(cx)
5500 .active_item()
5501 .and_then(|item| item.downcast::<ProjectSearchView>())
5502 })
5503 .expect("Search view should be active after deploy");
5504
5505 search_view.update_in(cx, |search_view, _window, cx| {
5506 assert!(
5507 search_view.search_options.contains(SearchOptions::REGEX),
5508 "Regex option should be enabled"
5509 );
5510 assert!(
5511 search_view
5512 .search_options
5513 .contains(SearchOptions::CASE_SENSITIVE),
5514 "Case sensitive option should be enabled"
5515 );
5516 assert!(
5517 search_view
5518 .search_options
5519 .contains(SearchOptions::WHOLE_WORD),
5520 "Whole word option should be enabled"
5521 );
5522 assert!(
5523 search_view
5524 .search_options
5525 .contains(SearchOptions::INCLUDE_IGNORED),
5526 "Include ignored option should be enabled"
5527 );
5528 let query_text = search_view.query_editor.read(cx).text(cx);
5529 assert_eq!(
5530 query_text, "Test_Query",
5531 "Query should be set from the action"
5532 );
5533 });
5534
5535 // Redeploy with only regex - unspecified options should be preserved.
5536 cx.dispatch_action(menu::Cancel);
5537 workspace.update_in(cx, |workspace, window, cx| {
5538 ProjectSearchView::deploy_search(
5539 workspace,
5540 &workspace::DeploySearch {
5541 regex: Some(true),
5542 ..Default::default()
5543 },
5544 window,
5545 cx,
5546 )
5547 });
5548
5549 search_view.update_in(cx, |search_view, _window, _cx| {
5550 assert!(
5551 search_view.search_options.contains(SearchOptions::REGEX),
5552 "Regex should still be enabled"
5553 );
5554 assert!(
5555 search_view
5556 .search_options
5557 .contains(SearchOptions::CASE_SENSITIVE),
5558 "Case sensitive should be preserved from previous deploy"
5559 );
5560 assert!(
5561 search_view
5562 .search_options
5563 .contains(SearchOptions::WHOLE_WORD),
5564 "Whole word should be preserved from previous deploy"
5565 );
5566 assert!(
5567 search_view
5568 .search_options
5569 .contains(SearchOptions::INCLUDE_IGNORED),
5570 "Include ignored should be preserved from previous deploy"
5571 );
5572 });
5573
5574 // Redeploy explicitly turning off options.
5575 cx.dispatch_action(menu::Cancel);
5576 workspace.update_in(cx, |workspace, window, cx| {
5577 ProjectSearchView::deploy_search(
5578 workspace,
5579 &workspace::DeploySearch {
5580 regex: Some(true),
5581 case_sensitive: Some(false),
5582 whole_word: Some(false),
5583 include_ignored: Some(false),
5584 ..Default::default()
5585 },
5586 window,
5587 cx,
5588 )
5589 });
5590
5591 search_view.update_in(cx, |search_view, _window, _cx| {
5592 assert_eq!(
5593 search_view.search_options,
5594 SearchOptions::REGEX,
5595 "Explicit Some(false) should turn off options"
5596 );
5597 });
5598
5599 // Redeploy with an empty query - should not overwrite the existing query.
5600 cx.dispatch_action(menu::Cancel);
5601 workspace.update_in(cx, |workspace, window, cx| {
5602 ProjectSearchView::deploy_search(
5603 workspace,
5604 &workspace::DeploySearch {
5605 query: Some("".into()),
5606 ..Default::default()
5607 },
5608 window,
5609 cx,
5610 )
5611 });
5612
5613 search_view.update_in(cx, |search_view, _window, cx| {
5614 let query_text = search_view.query_editor.read(cx).text(cx);
5615 assert_eq!(
5616 query_text, "Test_Query",
5617 "Empty query string should not overwrite the existing query"
5618 );
5619 });
5620 }
5621
5622 #[gpui::test]
5623 async fn test_smartcase_overrides_explicit_case_sensitive(cx: &mut TestAppContext) {
5624 init_test(cx);
5625
5626 cx.update(|cx| {
5627 cx.update_global::<SettingsStore, _>(|store, cx| {
5628 store.update_default_settings(cx, |settings| {
5629 settings.editor.use_smartcase_search = Some(true);
5630 });
5631 });
5632 });
5633
5634 let fs = FakeFs::new(cx.background_executor.clone());
5635 fs.insert_tree(
5636 path!("/dir"),
5637 json!({
5638 "one.rs": "const ONE: usize = 1;",
5639 }),
5640 )
5641 .await;
5642 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
5643 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
5644 let workspace = window
5645 .read_with(cx, |mw, _| mw.workspace().clone())
5646 .unwrap();
5647 let cx = &mut VisualTestContext::from_window(window.into(), cx);
5648 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
5649
5650 workspace.update_in(cx, |workspace, window, cx| {
5651 workspace.panes()[0].update(cx, |pane, cx| {
5652 pane.toolbar()
5653 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
5654 });
5655
5656 ProjectSearchView::deploy_search(
5657 workspace,
5658 &workspace::DeploySearch {
5659 case_sensitive: Some(true),
5660 query: Some("lowercase_query".into()),
5661 ..Default::default()
5662 },
5663 window,
5664 cx,
5665 )
5666 });
5667
5668 let search_view = cx
5669 .read(|cx| {
5670 workspace
5671 .read(cx)
5672 .active_pane()
5673 .read(cx)
5674 .active_item()
5675 .and_then(|item| item.downcast::<ProjectSearchView>())
5676 })
5677 .expect("Search view should be active after deploy");
5678
5679 // Smartcase should override the explicit case_sensitive flag
5680 // because the query is all lowercase.
5681 search_view.update_in(cx, |search_view, _window, cx| {
5682 assert!(
5683 !search_view
5684 .search_options
5685 .contains(SearchOptions::CASE_SENSITIVE),
5686 "Smartcase should disable case sensitivity for a lowercase query, \
5687 even when case_sensitive was explicitly set in the action"
5688 );
5689 let query_text = search_view.query_editor.read(cx).text(cx);
5690 assert_eq!(query_text, "lowercase_query");
5691 });
5692
5693 // Now deploy with an uppercase query - smartcase should enable case sensitivity.
5694 workspace.update_in(cx, |workspace, window, cx| {
5695 ProjectSearchView::deploy_search(
5696 workspace,
5697 &workspace::DeploySearch {
5698 query: Some("Uppercase_Query".into()),
5699 ..Default::default()
5700 },
5701 window,
5702 cx,
5703 )
5704 });
5705
5706 search_view.update_in(cx, |search_view, _window, cx| {
5707 assert!(
5708 search_view
5709 .search_options
5710 .contains(SearchOptions::CASE_SENSITIVE),
5711 "Smartcase should enable case sensitivity for a query containing uppercase"
5712 );
5713 let query_text = search_view.query_editor.read(cx).text(cx);
5714 assert_eq!(query_text, "Uppercase_Query");
5715 });
5716 }
5717
5718 #[gpui::test]
5719 async fn test_incremental_search_narrows_and_widens(cx: &mut TestAppContext) {
5720 init_test(cx);
5721
5722 // Two matches 5 lines apart: with context_line_count=2, contexts
5723 // [3..7] and [8..12] are adjacent and merge into a single excerpt
5724 // [3..12]. Narrowing to "targeted" produces context [3..7] ⊂ [3..12]
5725 // — the expand_new_ranges_to_existing fix ensures zero excerpt events.
5726 let mut lines: Vec<String> = (0..20).map(|i| format!("line {i}: filler")).collect();
5727 lines[5] = "line 5: targeted item".into();
5728 lines[10] = "line 10: target item".into();
5729 let big_file = lines.join("\n");
5730
5731 let fs = FakeFs::new(cx.background_executor.clone());
5732 fs.insert_tree(
5733 path!("/dir"),
5734 json!({
5735 "one.rs": "const ONE: usize = 1;\nconst ONEROUS: usize = 2;",
5736 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
5737 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
5738 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
5739 "only_one.rs": "const ONLY_ONE: usize = 1;",
5740 "big.txt": big_file,
5741 }),
5742 )
5743 .await;
5744 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
5745 let window =
5746 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5747 let workspace = window
5748 .read_with(cx, |mw, _| mw.workspace().clone())
5749 .unwrap();
5750 let search = cx.new(|cx| ProjectSearch::new(project.clone(), cx));
5751 let search_view = cx.add_window(|window, cx| {
5752 ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
5753 });
5754 let (events, _subscription) = subscribe_to_excerpt_events(&search, cx);
5755 let all_rs_files = vec!["four.rs", "one.rs", "only_one.rs", "three.rs", "two.rs"];
5756 let expected_one_matches = vec![
5757 "one", "ONE", "ONE", "ONE", "ONE", "one", "ONE", "one", "ONE", "one", "ONE",
5758 ];
5759
5760 // Initial non-incremental search for "ONE" — inserts one excerpt per file.
5761 perform_search(search_view, "ONE", cx);
5762 assert_eq!(read_match_texts(search_view, cx), expected_one_matches);
5763 assert_all_highlights_match_query(search_view, "ONE", cx);
5764 assert_eq!(take_updated_files(&events), all_rs_files);
5765
5766 // Natural narrowing: "ONE" -> "ONER". Only one.rs has ONEROUS.
5767 perform_incremental_search(search_view, "ONER", cx);
5768 assert_eq!(read_match_texts(search_view, cx), vec!["ONER"]);
5769 assert_all_highlights_match_query(search_view, "ONER", cx);
5770 assert_eq!(take_updated_files(&events), vec!["one.rs"]);
5771
5772 // Continue narrowing: "ONER" -> "ONEROUS". Still one.rs only.
5773 perform_incremental_search(search_view, "ONEROUS", cx);
5774 assert_eq!(read_match_texts(search_view, cx), vec!["ONEROUS"]);
5775 assert_all_highlights_match_query(search_view, "ONEROUS", cx);
5776 assert_eq!(take_updated_files(&events), vec!["one.rs"]);
5777
5778 // Backspace to "ONER" — still one.rs only.
5779 perform_incremental_search(search_view, "ONER", cx);
5780 assert_eq!(read_match_texts(search_view, cx), vec!["ONER"]);
5781 assert_all_highlights_match_query(search_view, "ONER", cx);
5782 assert_eq!(take_updated_files(&events), vec!["one.rs"]);
5783
5784 // Backspace to "ONE" — all 5 files re-appear.
5785 perform_incremental_search(search_view, "ONE", cx);
5786 assert_eq!(read_match_texts(search_view, cx), expected_one_matches);
5787 assert_all_highlights_match_query(search_view, "ONE", cx);
5788 assert_eq!(take_updated_files(&events), all_rs_files);
5789
5790 // Repeat the same "ONE" query — excerpts still get updated.
5791 perform_incremental_search(search_view, "ONE", cx);
5792 assert_eq!(read_match_texts(search_view, cx), expected_one_matches);
5793 assert_all_highlights_match_query(search_view, "ONE", cx);
5794 assert_eq!(take_updated_files(&events), all_rs_files);
5795
5796 // Narrow to "ONLY_ONE" — single match in only_one.rs.
5797 perform_incremental_search(search_view, "ONLY_ONE", cx);
5798 assert_eq!(read_match_texts(search_view, cx), vec!["ONLY_ONE"]);
5799 assert_all_highlights_match_query(search_view, "ONLY_ONE", cx);
5800 assert_eq!(take_updated_files(&events), vec!["only_one.rs"]);
5801
5802 // Widen back to "ONE" — all 5 files re-appear.
5803 perform_incremental_search(search_view, "ONE", cx);
5804 assert_eq!(read_match_texts(search_view, cx), expected_one_matches);
5805 assert_all_highlights_match_query(search_view, "ONE", cx);
5806 assert_eq!(take_updated_files(&events), all_rs_files);
5807
5808 // Narrowing when all files still match: "usize" -> "usize =".
5809 // Same file set, same match count.
5810 perform_search(search_view, "usize", cx);
5811 assert_eq!(read_match_count(search_view, cx), 6);
5812 events.borrow_mut().clear();
5813 perform_incremental_search(search_view, "usize =", cx);
5814 assert_eq!(read_match_count(search_view, cx), 6);
5815 assert_eq!(take_updated_files(&events), all_rs_files);
5816
5817 // Merged-excerpt narrowing: "target" matches lines 5 and 10 in big.txt.
5818 // Narrowing to "targeted" drops to 1 match but big.txt stays.
5819 perform_search(search_view, "target", cx);
5820 assert_all_highlights_match_query(search_view, "target", cx);
5821 assert_eq!(read_match_count(search_view, cx), 2);
5822 events.borrow_mut().clear();
5823
5824 perform_incremental_search(search_view, "targeted", cx);
5825 assert_all_highlights_match_query(search_view, "targeted", cx);
5826 assert_eq!(read_match_count(search_view, cx), 1);
5827 assert_eq!(take_updated_files(&events), vec!["big.txt"]);
5828 }
5829
5830 #[gpui::test]
5831 async fn test_incremental_search_clears_stale_excerpts_on_query_change(
5832 cx: &mut TestAppContext,
5833 ) {
5834 init_test(cx);
5835
5836 let fs = FakeFs::new(cx.background_executor.clone());
5837 fs.insert_tree(
5838 path!("/dir"),
5839 json!({
5840 "alpha.rs": "fn alpha() {}\nfn alpha_bravo() {}",
5841 "bravo.rs": "fn bravo() { alpha() }",
5842 "charlie.rs": "fn charlie() { alpha(); bravo() }",
5843 "delta.rs": "fn delta_unique_word() {}",
5844 }),
5845 )
5846 .await;
5847 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
5848 let window =
5849 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5850 let workspace = window
5851 .read_with(cx, |mw, _| mw.workspace().clone())
5852 .unwrap();
5853 let search = cx.new(|cx| ProjectSearch::new(project.clone(), cx));
5854 let search_view = cx.add_window(|window, cx| {
5855 ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
5856 });
5857 let (events, _subscription) = subscribe_to_excerpt_events(&search, cx);
5858
5859 // Search for "alpha" — matches in alpha.rs, bravo.rs, charlie.rs.
5860 perform_search(search_view, "alpha", cx);
5861 let alpha_files = take_updated_files(&events);
5862 assert_eq!(alpha_files, vec!["alpha.rs", "bravo.rs", "charlie.rs"]);
5863 assert!(read_match_count(search_view, cx) > 0);
5864
5865 // Incremental search for "delta_unique_word" — only delta.rs should match.
5866 // Before the fix, stale excerpts from "alpha" search would linger.
5867 perform_incremental_search(search_view, "delta_unique_word", cx);
5868 let delta_files = take_updated_files(&events);
5869 assert_eq!(delta_files, vec!["delta.rs"]);
5870 assert_eq!(read_match_count(search_view, cx), 1);
5871 assert_eq!(read_match_texts(search_view, cx), vec!["delta_unique_word"]);
5872 }
5873
5874 #[gpui::test]
5875 async fn test_search_on_input_keeps_focus_confirm_shifts_it(cx: &mut TestAppContext) {
5876 init_test(cx);
5877 cx.update(|cx| {
5878 SettingsStore::update_global(cx, |store, cx| {
5879 store.update_user_settings(cx, |settings| {
5880 settings
5881 .editor
5882 .search
5883 .get_or_insert_default()
5884 .search_on_input = Some(true);
5885 });
5886 });
5887 });
5888
5889 let fs = FakeFs::new(cx.background_executor.clone());
5890 fs.insert_tree(
5891 path!("/dir"),
5892 json!({
5893 "one.rs": "const ONE: usize = 1;",
5894 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
5895 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
5896 }),
5897 )
5898 .await;
5899 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
5900 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
5901 let workspace = window
5902 .read_with(cx, |mw, _| mw.workspace().clone())
5903 .unwrap();
5904 let cx = &mut VisualTestContext::from_window(window.into(), cx);
5905 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
5906
5907 workspace.update_in(cx, |workspace, window, cx| {
5908 workspace.panes()[0].update(cx, |pane, cx| {
5909 pane.toolbar()
5910 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
5911 });
5912 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx);
5913 });
5914
5915 let search_view = cx
5916 .read(|cx| {
5917 workspace
5918 .read(cx)
5919 .active_pane()
5920 .read(cx)
5921 .active_item()
5922 .and_then(|item| item.downcast::<ProjectSearchView>())
5923 })
5924 .expect("Search view expected to appear after new search event trigger");
5925
5926 // Type into the query editor — with search_on_input the EditorEvent::Edited
5927 // subscriber triggers an incremental search. The query editor must stay focused.
5928 window
5929 .update(cx, |_, window, cx| {
5930 search_view.update(cx, |search_view, cx| {
5931 search_view.query_editor.update(cx, |query_editor, cx| {
5932 query_editor.set_text("ONE", window, cx);
5933 });
5934 });
5935 })
5936 .unwrap();
5937 cx.background_executor.run_until_parked();
5938
5939 window
5940 .update(cx, |_, window, cx| {
5941 search_view.update(cx, |search_view, cx| {
5942 assert!(
5943 !search_view.entity.read(cx).match_ranges.is_empty(),
5944 "Incremental search should have found matches",
5945 );
5946 assert!(
5947 search_view.query_editor.focus_handle(cx).is_focused(window),
5948 "Query editor should remain focused after incremental search with search_on_input",
5949 );
5950 assert!(
5951 !search_view.results_editor.focus_handle(cx).is_focused(window),
5952 "Results editor should not be focused after incremental search with search_on_input",
5953 );
5954 });
5955 })
5956 .unwrap();
5957
5958 // Move the results editor cursor to the end of the buffer so it's
5959 // far away from the first match, then re-focus the query editor.
5960 window
5961 .update(cx, |_, window, cx| {
5962 search_view.update(cx, |search_view, cx| {
5963 search_view.results_editor.update(cx, |editor, cx| {
5964 editor.move_to_end(&editor::actions::MoveToEnd, window, cx);
5965 });
5966 let ranges = search_view.results_editor.update(cx, |editor, cx| {
5967 editor
5968 .selections
5969 .display_ranges(&editor.display_snapshot(cx))
5970 });
5971 assert_eq!(ranges.len(), 1);
5972 assert_ne!(
5973 ranges[0].start,
5974 DisplayPoint::new(DisplayRow(0), 0),
5975 "Cursor should be past the start of the buffer after MoveToEnd",
5976 );
5977 search_view.query_editor.focus_handle(cx).focus(window, cx);
5978 });
5979 })
5980 .unwrap();
5981
5982 // Press Enter (Confirm) via the search bar — this should shift focus
5983 // to the results editor with the first match selected, regardless of
5984 // where the cursor was before.
5985 cx.dispatch_action(Confirm);
5986 cx.background_executor.run_until_parked();
5987
5988 window
5989 .update(cx, |_, window, cx| {
5990 search_view.update(cx, |search_view, cx| {
5991 assert!(
5992 !search_view.entity.read(cx).match_ranges.is_empty(),
5993 "Confirm search should have found matches",
5994 );
5995 assert!(
5996 search_view.results_editor.focus_handle(cx).is_focused(window),
5997 "Results editor should be focused after confirming search with search_on_input",
5998 );
5999 assert!(
6000 !search_view.query_editor.focus_handle(cx).is_focused(window),
6001 "Query editor should not be focused after confirming search with search_on_input",
6002 );
6003 assert_eq!(
6004 search_view.active_match_index,
6005 Some(0),
6006 "First match should be selected after confirm",
6007 );
6008 let results_editor = search_view.results_editor.read(cx);
6009 let selection = results_editor.selections.newest_anchor();
6010 let snapshot = results_editor.buffer().read(cx).snapshot(cx);
6011 let selected_text = snapshot
6012 .text_for_range(selection.start..selection.end)
6013 .collect::<String>();
6014 assert_eq!(
6015 selected_text, "ONE",
6016 "First match text should be selected after confirm",
6017 );
6018 });
6019 })
6020 .unwrap();
6021 }
6022}