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