project_search.rs

   1use crate::{
   2    SearchOptions, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleRegex,
   3    ToggleWholeWord,
   4};
   5use anyhow::Context;
   6use collections::HashMap;
   7use editor::{
   8    items::active_match_index, scroll::autoscroll::Autoscroll, Anchor, Editor, MultiBuffer,
   9    SelectAll, MAX_TAB_TITLE_LEN,
  10};
  11use futures::StreamExt;
  12use gpui::{
  13    actions,
  14    elements::*,
  15    platform::{CursorStyle, MouseButton},
  16    Action, AnyElement, AnyViewHandle, AppContext, Entity, ModelContext, ModelHandle, Subscription,
  17    Task, View, ViewContext, ViewHandle, WeakModelHandle, WeakViewHandle,
  18};
  19use menu::Confirm;
  20use postage::stream::Stream;
  21use project::{
  22    search::{PathMatcher, SearchQuery},
  23    Entry, Project,
  24};
  25use semantic_index::SemanticIndex;
  26use smallvec::SmallVec;
  27use std::{
  28    any::{Any, TypeId},
  29    borrow::Cow,
  30    collections::HashSet,
  31    mem,
  32    ops::{Not, Range},
  33    path::PathBuf,
  34    sync::Arc,
  35};
  36use util::ResultExt as _;
  37use workspace::{
  38    item::{BreadcrumbText, Item, ItemEvent, ItemHandle},
  39    searchable::{Direction, SearchableItem, SearchableItemHandle},
  40    ItemNavHistory, Pane, ToolbarItemLocation, ToolbarItemView, Workspace, WorkspaceId,
  41};
  42
  43actions!(
  44    project_search,
  45    [SearchInNew, ToggleFocus, NextField, ToggleSemanticSearch]
  46);
  47
  48#[derive(Default)]
  49struct ActiveSearches(HashMap<WeakModelHandle<Project>, WeakViewHandle<ProjectSearchView>>);
  50
  51pub fn init(cx: &mut AppContext) {
  52    cx.set_global(ActiveSearches::default());
  53    cx.add_action(ProjectSearchView::deploy);
  54    cx.add_action(ProjectSearchView::move_focus_to_results);
  55    cx.add_action(ProjectSearchBar::search);
  56    cx.add_action(ProjectSearchBar::search_in_new);
  57    cx.add_action(ProjectSearchBar::select_next_match);
  58    cx.add_action(ProjectSearchBar::select_prev_match);
  59    cx.capture_action(ProjectSearchBar::tab);
  60    cx.capture_action(ProjectSearchBar::tab_previous);
  61    add_toggle_option_action::<ToggleCaseSensitive>(SearchOptions::CASE_SENSITIVE, cx);
  62    add_toggle_option_action::<ToggleWholeWord>(SearchOptions::WHOLE_WORD, cx);
  63    add_toggle_option_action::<ToggleRegex>(SearchOptions::REGEX, cx);
  64}
  65
  66fn add_toggle_option_action<A: Action>(option: SearchOptions, cx: &mut AppContext) {
  67    cx.add_action(move |pane: &mut Pane, _: &A, cx: &mut ViewContext<Pane>| {
  68        if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<ProjectSearchBar>() {
  69            if search_bar.update(cx, |search_bar, cx| {
  70                search_bar.toggle_search_option(option, cx)
  71            }) {
  72                return;
  73            }
  74        }
  75        cx.propagate_action();
  76    });
  77}
  78
  79struct ProjectSearch {
  80    project: ModelHandle<Project>,
  81    excerpts: ModelHandle<MultiBuffer>,
  82    pending_search: Option<Task<Option<()>>>,
  83    match_ranges: Vec<Range<Anchor>>,
  84    active_query: Option<SearchQuery>,
  85    search_id: usize,
  86}
  87
  88#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
  89enum InputPanel {
  90    Query,
  91    Exclude,
  92    Include,
  93}
  94
  95pub struct ProjectSearchView {
  96    model: ModelHandle<ProjectSearch>,
  97    query_editor: ViewHandle<Editor>,
  98    results_editor: ViewHandle<Editor>,
  99    semantic: Option<SemanticSearchState>,
 100    search_options: SearchOptions,
 101    panels_with_errors: HashSet<InputPanel>,
 102    active_match_index: Option<usize>,
 103    search_id: usize,
 104    query_editor_was_focused: bool,
 105    included_files_editor: ViewHandle<Editor>,
 106    excluded_files_editor: ViewHandle<Editor>,
 107}
 108
 109struct SemanticSearchState {
 110    file_count: usize,
 111    outstanding_file_count: usize,
 112    _progress_task: Task<()>,
 113}
 114
 115pub struct ProjectSearchBar {
 116    active_project_search: Option<ViewHandle<ProjectSearchView>>,
 117    subscription: Option<Subscription>,
 118}
 119
 120impl Entity for ProjectSearch {
 121    type Event = ();
 122}
 123
 124impl ProjectSearch {
 125    fn new(project: ModelHandle<Project>, cx: &mut ModelContext<Self>) -> Self {
 126        let replica_id = project.read(cx).replica_id();
 127        Self {
 128            project,
 129            excerpts: cx.add_model(|_| MultiBuffer::new(replica_id)),
 130            pending_search: Default::default(),
 131            match_ranges: Default::default(),
 132            active_query: None,
 133            search_id: 0,
 134        }
 135    }
 136
 137    fn clone(&self, cx: &mut ModelContext<Self>) -> ModelHandle<Self> {
 138        cx.add_model(|cx| Self {
 139            project: self.project.clone(),
 140            excerpts: self
 141                .excerpts
 142                .update(cx, |excerpts, cx| cx.add_model(|cx| excerpts.clone(cx))),
 143            pending_search: Default::default(),
 144            match_ranges: self.match_ranges.clone(),
 145            active_query: self.active_query.clone(),
 146            search_id: self.search_id,
 147        })
 148    }
 149
 150    fn search(&mut self, query: SearchQuery, cx: &mut ModelContext<Self>) {
 151        let search = self
 152            .project
 153            .update(cx, |project, cx| project.search(query.clone(), cx));
 154        self.search_id += 1;
 155        self.active_query = Some(query);
 156        self.match_ranges.clear();
 157        self.pending_search = Some(cx.spawn_weak(|this, mut cx| async move {
 158            let matches = search.await.log_err()?;
 159            let this = this.upgrade(&cx)?;
 160            let mut matches = matches.into_iter().collect::<Vec<_>>();
 161            let (_task, mut match_ranges) = this.update(&mut cx, |this, cx| {
 162                this.match_ranges.clear();
 163                matches.sort_by_key(|(buffer, _)| buffer.read(cx).file().map(|file| file.path()));
 164                this.excerpts.update(cx, |excerpts, cx| {
 165                    excerpts.clear(cx);
 166                    excerpts.stream_excerpts_with_context_lines(matches, 1, cx)
 167                })
 168            });
 169
 170            while let Some(match_range) = match_ranges.next().await {
 171                this.update(&mut cx, |this, cx| {
 172                    this.match_ranges.push(match_range);
 173                    while let Ok(Some(match_range)) = match_ranges.try_next() {
 174                        this.match_ranges.push(match_range);
 175                    }
 176                    cx.notify();
 177                });
 178            }
 179
 180            this.update(&mut cx, |this, cx| {
 181                this.pending_search.take();
 182                cx.notify();
 183            });
 184
 185            None
 186        }));
 187        cx.notify();
 188    }
 189
 190    fn semantic_search(&mut self, query: SearchQuery, cx: &mut ModelContext<Self>) {
 191        let search = SemanticIndex::global(cx).map(|index| {
 192            index.update(cx, |semantic_index, cx| {
 193                semantic_index.search_project(
 194                    self.project.clone(),
 195                    query.as_str().to_owned(),
 196                    10,
 197                    query.files_to_include().to_vec(),
 198                    query.files_to_exclude().to_vec(),
 199                    cx,
 200                )
 201            })
 202        });
 203        self.search_id += 1;
 204        self.match_ranges.clear();
 205        self.pending_search = Some(cx.spawn(|this, mut cx| async move {
 206            let results = search?.await.log_err()?;
 207
 208            let (_task, mut match_ranges) = this.update(&mut cx, |this, cx| {
 209                this.excerpts.update(cx, |excerpts, cx| {
 210                    excerpts.clear(cx);
 211
 212                    let matches = results
 213                        .into_iter()
 214                        .map(|result| (result.buffer, vec![result.range.start..result.range.start]))
 215                        .collect();
 216
 217                    excerpts.stream_excerpts_with_context_lines(matches, 3, cx)
 218                })
 219            });
 220
 221            while let Some(match_range) = match_ranges.next().await {
 222                this.update(&mut cx, |this, cx| {
 223                    this.match_ranges.push(match_range);
 224                    while let Ok(Some(match_range)) = match_ranges.try_next() {
 225                        this.match_ranges.push(match_range);
 226                    }
 227                    cx.notify();
 228                });
 229            }
 230
 231            this.update(&mut cx, |this, cx| {
 232                this.pending_search.take();
 233                cx.notify();
 234            });
 235
 236            None
 237        }));
 238        cx.notify();
 239    }
 240}
 241
 242pub enum ViewEvent {
 243    UpdateTab,
 244    Activate,
 245    EditorEvent(editor::Event),
 246}
 247
 248impl Entity for ProjectSearchView {
 249    type Event = ViewEvent;
 250}
 251
 252impl View for ProjectSearchView {
 253    fn ui_name() -> &'static str {
 254        "ProjectSearchView"
 255    }
 256
 257    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
 258        let model = &self.model.read(cx);
 259        if model.match_ranges.is_empty() {
 260            enum Status {}
 261
 262            let theme = theme::current(cx).clone();
 263            let text = if model.pending_search.is_some() {
 264                Cow::Borrowed("Searching...")
 265            } else if let Some(semantic) = &self.semantic {
 266                if semantic.outstanding_file_count > 0 {
 267                    Cow::Owned(format!(
 268                        "Indexing. {} of {}...",
 269                        semantic.file_count - semantic.outstanding_file_count,
 270                        semantic.file_count
 271                    ))
 272                } else {
 273                    Cow::Borrowed("Indexing complete")
 274                }
 275            } else if self.query_editor.read(cx).text(cx).is_empty() {
 276                Cow::Borrowed("")
 277            } else {
 278                Cow::Borrowed("No results")
 279            };
 280
 281            MouseEventHandler::<Status, _>::new(0, cx, |_, _| {
 282                Label::new(text, theme.search.results_status.clone())
 283                    .aligned()
 284                    .contained()
 285                    .with_background_color(theme.editor.background)
 286                    .flex(1., true)
 287            })
 288            .on_down(MouseButton::Left, |_, _, cx| {
 289                cx.focus_parent();
 290            })
 291            .into_any_named("project search view")
 292        } else {
 293            ChildView::new(&self.results_editor, cx)
 294                .flex(1., true)
 295                .into_any_named("project search view")
 296        }
 297    }
 298
 299    fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
 300        let handle = cx.weak_handle();
 301        cx.update_global(|state: &mut ActiveSearches, cx| {
 302            state
 303                .0
 304                .insert(self.model.read(cx).project.downgrade(), handle)
 305        });
 306
 307        if cx.is_self_focused() {
 308            if self.query_editor_was_focused {
 309                cx.focus(&self.query_editor);
 310            } else {
 311                cx.focus(&self.results_editor);
 312            }
 313        }
 314    }
 315}
 316
 317impl Item for ProjectSearchView {
 318    fn tab_tooltip_text(&self, cx: &AppContext) -> Option<Cow<str>> {
 319        let query_text = self.query_editor.read(cx).text(cx);
 320
 321        query_text
 322            .is_empty()
 323            .not()
 324            .then(|| query_text.into())
 325            .or_else(|| Some("Project Search".into()))
 326    }
 327
 328    fn act_as_type<'a>(
 329        &'a self,
 330        type_id: TypeId,
 331        self_handle: &'a ViewHandle<Self>,
 332        _: &'a AppContext,
 333    ) -> Option<&'a AnyViewHandle> {
 334        if type_id == TypeId::of::<Self>() {
 335            Some(self_handle)
 336        } else if type_id == TypeId::of::<Editor>() {
 337            Some(&self.results_editor)
 338        } else {
 339            None
 340        }
 341    }
 342
 343    fn deactivated(&mut self, cx: &mut ViewContext<Self>) {
 344        self.results_editor
 345            .update(cx, |editor, cx| editor.deactivated(cx));
 346    }
 347
 348    fn tab_content<T: View>(
 349        &self,
 350        _detail: Option<usize>,
 351        tab_theme: &theme::Tab,
 352        cx: &AppContext,
 353    ) -> AnyElement<T> {
 354        Flex::row()
 355            .with_child(
 356                Svg::new("icons/magnifying_glass_12.svg")
 357                    .with_color(tab_theme.label.text.color)
 358                    .constrained()
 359                    .with_width(tab_theme.type_icon_width)
 360                    .aligned()
 361                    .contained()
 362                    .with_margin_right(tab_theme.spacing),
 363            )
 364            .with_children(self.model.read(cx).active_query.as_ref().map(|query| {
 365                let query_text = util::truncate_and_trailoff(query.as_str(), MAX_TAB_TITLE_LEN);
 366
 367                Label::new(query_text, tab_theme.label.clone()).aligned()
 368            }))
 369            .into_any()
 370    }
 371
 372    fn for_each_project_item(&self, cx: &AppContext, f: &mut dyn FnMut(usize, &dyn project::Item)) {
 373        self.results_editor.for_each_project_item(cx, f)
 374    }
 375
 376    fn is_singleton(&self, _: &AppContext) -> bool {
 377        false
 378    }
 379
 380    fn can_save(&self, _: &AppContext) -> bool {
 381        true
 382    }
 383
 384    fn is_dirty(&self, cx: &AppContext) -> bool {
 385        self.results_editor.read(cx).is_dirty(cx)
 386    }
 387
 388    fn has_conflict(&self, cx: &AppContext) -> bool {
 389        self.results_editor.read(cx).has_conflict(cx)
 390    }
 391
 392    fn save(
 393        &mut self,
 394        project: ModelHandle<Project>,
 395        cx: &mut ViewContext<Self>,
 396    ) -> Task<anyhow::Result<()>> {
 397        self.results_editor
 398            .update(cx, |editor, cx| editor.save(project, cx))
 399    }
 400
 401    fn save_as(
 402        &mut self,
 403        _: ModelHandle<Project>,
 404        _: PathBuf,
 405        _: &mut ViewContext<Self>,
 406    ) -> Task<anyhow::Result<()>> {
 407        unreachable!("save_as should not have been called")
 408    }
 409
 410    fn reload(
 411        &mut self,
 412        project: ModelHandle<Project>,
 413        cx: &mut ViewContext<Self>,
 414    ) -> Task<anyhow::Result<()>> {
 415        self.results_editor
 416            .update(cx, |editor, cx| editor.reload(project, cx))
 417    }
 418
 419    fn clone_on_split(&self, _workspace_id: WorkspaceId, cx: &mut ViewContext<Self>) -> Option<Self>
 420    where
 421        Self: Sized,
 422    {
 423        let model = self.model.update(cx, |model, cx| model.clone(cx));
 424        Some(Self::new(model, cx))
 425    }
 426
 427    fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext<Self>) {
 428        self.results_editor
 429            .update(cx, |editor, cx| editor.added_to_workspace(workspace, cx));
 430    }
 431
 432    fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut ViewContext<Self>) {
 433        self.results_editor.update(cx, |editor, _| {
 434            editor.set_nav_history(Some(nav_history));
 435        });
 436    }
 437
 438    fn navigate(&mut self, data: Box<dyn Any>, cx: &mut ViewContext<Self>) -> bool {
 439        self.results_editor
 440            .update(cx, |editor, cx| editor.navigate(data, cx))
 441    }
 442
 443    fn to_item_events(event: &Self::Event) -> SmallVec<[ItemEvent; 2]> {
 444        match event {
 445            ViewEvent::UpdateTab => {
 446                smallvec::smallvec![ItemEvent::UpdateBreadcrumbs, ItemEvent::UpdateTab]
 447            }
 448            ViewEvent::EditorEvent(editor_event) => Editor::to_item_events(editor_event),
 449            _ => SmallVec::new(),
 450        }
 451    }
 452
 453    fn breadcrumb_location(&self) -> ToolbarItemLocation {
 454        if self.has_matches() {
 455            ToolbarItemLocation::Secondary
 456        } else {
 457            ToolbarItemLocation::Hidden
 458        }
 459    }
 460
 461    fn breadcrumbs(&self, theme: &theme::Theme, cx: &AppContext) -> Option<Vec<BreadcrumbText>> {
 462        self.results_editor.breadcrumbs(theme, cx)
 463    }
 464
 465    fn serialized_item_kind() -> Option<&'static str> {
 466        None
 467    }
 468
 469    fn deserialize(
 470        _project: ModelHandle<Project>,
 471        _workspace: WeakViewHandle<Workspace>,
 472        _workspace_id: workspace::WorkspaceId,
 473        _item_id: workspace::ItemId,
 474        _cx: &mut ViewContext<Pane>,
 475    ) -> Task<anyhow::Result<ViewHandle<Self>>> {
 476        unimplemented!()
 477    }
 478}
 479
 480impl ProjectSearchView {
 481    fn new(model: ModelHandle<ProjectSearch>, cx: &mut ViewContext<Self>) -> Self {
 482        let project;
 483        let excerpts;
 484        let mut query_text = String::new();
 485        let mut options = SearchOptions::NONE;
 486
 487        {
 488            let model = model.read(cx);
 489            project = model.project.clone();
 490            excerpts = model.excerpts.clone();
 491            if let Some(active_query) = model.active_query.as_ref() {
 492                query_text = active_query.as_str().to_string();
 493                options = SearchOptions::from_query(active_query);
 494            }
 495        }
 496        cx.observe(&model, |this, _, cx| this.model_changed(cx))
 497            .detach();
 498
 499        let query_editor = cx.add_view(|cx| {
 500            let mut editor = Editor::single_line(
 501                Some(Arc::new(|theme| theme.search.editor.input.clone())),
 502                cx,
 503            );
 504            editor.set_text(query_text, cx);
 505            editor
 506        });
 507        // Subscribe to query_editor in order to reraise editor events for workspace item activation purposes
 508        cx.subscribe(&query_editor, |_, _, event, cx| {
 509            cx.emit(ViewEvent::EditorEvent(event.clone()))
 510        })
 511        .detach();
 512
 513        let results_editor = cx.add_view(|cx| {
 514            let mut editor = Editor::for_multibuffer(excerpts, Some(project), cx);
 515            editor.set_searchable(false);
 516            editor
 517        });
 518        cx.observe(&results_editor, |_, _, cx| cx.emit(ViewEvent::UpdateTab))
 519            .detach();
 520
 521        cx.subscribe(&results_editor, |this, _, event, cx| {
 522            if matches!(event, editor::Event::SelectionsChanged { .. }) {
 523                this.update_match_index(cx);
 524            }
 525            // Reraise editor events for workspace item activation purposes
 526            cx.emit(ViewEvent::EditorEvent(event.clone()));
 527        })
 528        .detach();
 529
 530        let included_files_editor = cx.add_view(|cx| {
 531            let mut editor = Editor::single_line(
 532                Some(Arc::new(|theme| {
 533                    theme.search.include_exclude_editor.input.clone()
 534                })),
 535                cx,
 536            );
 537            editor.set_placeholder_text("Include: crates/**/*.toml", cx);
 538
 539            editor
 540        });
 541        // Subscribe to include_files_editor in order to reraise editor events for workspace item activation purposes
 542        cx.subscribe(&included_files_editor, |_, _, event, cx| {
 543            cx.emit(ViewEvent::EditorEvent(event.clone()))
 544        })
 545        .detach();
 546
 547        let excluded_files_editor = cx.add_view(|cx| {
 548            let mut editor = Editor::single_line(
 549                Some(Arc::new(|theme| {
 550                    theme.search.include_exclude_editor.input.clone()
 551                })),
 552                cx,
 553            );
 554            editor.set_placeholder_text("Exclude: vendor/*, *.lock", cx);
 555
 556            editor
 557        });
 558        // Subscribe to excluded_files_editor in order to reraise editor events for workspace item activation purposes
 559        cx.subscribe(&excluded_files_editor, |_, _, event, cx| {
 560            cx.emit(ViewEvent::EditorEvent(event.clone()))
 561        })
 562        .detach();
 563
 564        let mut this = ProjectSearchView {
 565            search_id: model.read(cx).search_id,
 566            model,
 567            query_editor,
 568            results_editor,
 569            semantic: None,
 570            search_options: options,
 571            panels_with_errors: HashSet::new(),
 572            active_match_index: None,
 573            query_editor_was_focused: false,
 574            included_files_editor,
 575            excluded_files_editor,
 576        };
 577        this.model_changed(cx);
 578        this
 579    }
 580
 581    pub fn new_search_in_directory(
 582        workspace: &mut Workspace,
 583        dir_entry: &Entry,
 584        cx: &mut ViewContext<Workspace>,
 585    ) {
 586        if !dir_entry.is_dir() {
 587            return;
 588        }
 589        let Some(filter_str) = dir_entry.path.to_str() else { return; };
 590
 591        let model = cx.add_model(|cx| ProjectSearch::new(workspace.project().clone(), cx));
 592        let search = cx.add_view(|cx| ProjectSearchView::new(model, cx));
 593        workspace.add_item(Box::new(search.clone()), cx);
 594        search.update(cx, |search, cx| {
 595            search
 596                .included_files_editor
 597                .update(cx, |editor, cx| editor.set_text(filter_str, cx));
 598            search.focus_query_editor(cx)
 599        });
 600    }
 601
 602    // Re-activate the most recently activated search or the most recent if it has been closed.
 603    // If no search exists in the workspace, create a new one.
 604    fn deploy(
 605        workspace: &mut Workspace,
 606        _: &workspace::NewSearch,
 607        cx: &mut ViewContext<Workspace>,
 608    ) {
 609        // Clean up entries for dropped projects
 610        cx.update_global(|state: &mut ActiveSearches, cx| {
 611            state.0.retain(|project, _| project.is_upgradable(cx))
 612        });
 613
 614        let active_search = cx
 615            .global::<ActiveSearches>()
 616            .0
 617            .get(&workspace.project().downgrade());
 618
 619        let existing = active_search
 620            .and_then(|active_search| {
 621                workspace
 622                    .items_of_type::<ProjectSearchView>(cx)
 623                    .find(|search| search == active_search)
 624            })
 625            .or_else(|| workspace.item_of_type::<ProjectSearchView>(cx));
 626
 627        let query = workspace.active_item(cx).and_then(|item| {
 628            let editor = item.act_as::<Editor>(cx)?;
 629            let query = editor.query_suggestion(cx);
 630            if query.is_empty() {
 631                None
 632            } else {
 633                Some(query)
 634            }
 635        });
 636
 637        let search = if let Some(existing) = existing {
 638            workspace.activate_item(&existing, cx);
 639            existing
 640        } else {
 641            let model = cx.add_model(|cx| ProjectSearch::new(workspace.project().clone(), cx));
 642            let view = cx.add_view(|cx| ProjectSearchView::new(model, cx));
 643            workspace.add_item(Box::new(view.clone()), cx);
 644            view
 645        };
 646
 647        search.update(cx, |search, cx| {
 648            if let Some(query) = query {
 649                search.set_query(&query, cx);
 650            }
 651            search.focus_query_editor(cx)
 652        });
 653    }
 654
 655    fn search(&mut self, cx: &mut ViewContext<Self>) {
 656        if let Some(semantic) = &mut self.semantic {
 657            if semantic.outstanding_file_count > 0 {
 658                return;
 659            }
 660            if let Some(query) = self.build_search_query(cx) {
 661                self.model
 662                    .update(cx, |model, cx| model.semantic_search(query, cx));
 663            }
 664        }
 665
 666        if let Some(query) = self.build_search_query(cx) {
 667            self.model.update(cx, |model, cx| model.search(query, cx));
 668        }
 669    }
 670
 671    fn build_search_query(&mut self, cx: &mut ViewContext<Self>) -> Option<SearchQuery> {
 672        let text = self.query_editor.read(cx).text(cx);
 673        let included_files =
 674            match Self::parse_path_matches(&self.included_files_editor.read(cx).text(cx)) {
 675                Ok(included_files) => {
 676                    self.panels_with_errors.remove(&InputPanel::Include);
 677                    included_files
 678                }
 679                Err(_e) => {
 680                    self.panels_with_errors.insert(InputPanel::Include);
 681                    cx.notify();
 682                    return None;
 683                }
 684            };
 685        let excluded_files =
 686            match Self::parse_path_matches(&self.excluded_files_editor.read(cx).text(cx)) {
 687                Ok(excluded_files) => {
 688                    self.panels_with_errors.remove(&InputPanel::Exclude);
 689                    excluded_files
 690                }
 691                Err(_e) => {
 692                    self.panels_with_errors.insert(InputPanel::Exclude);
 693                    cx.notify();
 694                    return None;
 695                }
 696            };
 697        if self.search_options.contains(SearchOptions::REGEX) {
 698            match SearchQuery::regex(
 699                text,
 700                self.search_options.contains(SearchOptions::WHOLE_WORD),
 701                self.search_options.contains(SearchOptions::CASE_SENSITIVE),
 702                included_files,
 703                excluded_files,
 704            ) {
 705                Ok(query) => {
 706                    self.panels_with_errors.remove(&InputPanel::Query);
 707                    Some(query)
 708                }
 709                Err(_e) => {
 710                    self.panels_with_errors.insert(InputPanel::Query);
 711                    cx.notify();
 712                    None
 713                }
 714            }
 715        } else {
 716            Some(SearchQuery::text(
 717                text,
 718                self.search_options.contains(SearchOptions::WHOLE_WORD),
 719                self.search_options.contains(SearchOptions::CASE_SENSITIVE),
 720                included_files,
 721                excluded_files,
 722            ))
 723        }
 724    }
 725
 726    fn parse_path_matches(text: &str) -> anyhow::Result<Vec<PathMatcher>> {
 727        text.split(',')
 728            .map(str::trim)
 729            .filter(|maybe_glob_str| !maybe_glob_str.is_empty())
 730            .map(|maybe_glob_str| {
 731                PathMatcher::new(maybe_glob_str)
 732                    .with_context(|| format!("parsing {maybe_glob_str} as path matcher"))
 733            })
 734            .collect()
 735    }
 736
 737    fn select_match(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
 738        if let Some(index) = self.active_match_index {
 739            let match_ranges = self.model.read(cx).match_ranges.clone();
 740            let new_index = self.results_editor.update(cx, |editor, cx| {
 741                editor.match_index_for_direction(&match_ranges, index, direction, 1, cx)
 742            });
 743
 744            let range_to_select = match_ranges[new_index].clone();
 745            self.results_editor.update(cx, |editor, cx| {
 746                let range_to_select = editor.range_for_match(&range_to_select);
 747                editor.unfold_ranges([range_to_select.clone()], false, true, cx);
 748                editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
 749                    s.select_ranges([range_to_select])
 750                });
 751            });
 752        }
 753    }
 754
 755    fn focus_query_editor(&mut self, cx: &mut ViewContext<Self>) {
 756        self.query_editor.update(cx, |query_editor, cx| {
 757            query_editor.select_all(&SelectAll, cx);
 758        });
 759        self.query_editor_was_focused = true;
 760        cx.focus(&self.query_editor);
 761    }
 762
 763    fn set_query(&mut self, query: &str, cx: &mut ViewContext<Self>) {
 764        self.query_editor
 765            .update(cx, |query_editor, cx| query_editor.set_text(query, cx));
 766    }
 767
 768    fn focus_results_editor(&mut self, cx: &mut ViewContext<Self>) {
 769        self.query_editor.update(cx, |query_editor, cx| {
 770            let cursor = query_editor.selections.newest_anchor().head();
 771            query_editor.change_selections(None, cx, |s| s.select_ranges([cursor.clone()..cursor]));
 772        });
 773        self.query_editor_was_focused = false;
 774        cx.focus(&self.results_editor);
 775    }
 776
 777    fn model_changed(&mut self, cx: &mut ViewContext<Self>) {
 778        let match_ranges = self.model.read(cx).match_ranges.clone();
 779        if match_ranges.is_empty() {
 780            self.active_match_index = None;
 781        } else {
 782            self.active_match_index = Some(0);
 783            self.update_match_index(cx);
 784            let prev_search_id = mem::replace(&mut self.search_id, self.model.read(cx).search_id);
 785            let is_new_search = self.search_id != prev_search_id;
 786            self.results_editor.update(cx, |editor, cx| {
 787                if is_new_search {
 788                    let range_to_select = match_ranges
 789                        .first()
 790                        .clone()
 791                        .map(|range| editor.range_for_match(range));
 792                    editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
 793                        s.select_ranges(range_to_select)
 794                    });
 795                }
 796                editor.highlight_background::<Self>(
 797                    match_ranges,
 798                    |theme| theme.search.match_background,
 799                    cx,
 800                );
 801            });
 802            if is_new_search && self.query_editor.is_focused(cx) {
 803                self.focus_results_editor(cx);
 804            }
 805        }
 806
 807        cx.emit(ViewEvent::UpdateTab);
 808        cx.notify();
 809    }
 810
 811    fn update_match_index(&mut self, cx: &mut ViewContext<Self>) {
 812        let results_editor = self.results_editor.read(cx);
 813        let new_index = active_match_index(
 814            &self.model.read(cx).match_ranges,
 815            &results_editor.selections.newest_anchor().head(),
 816            &results_editor.buffer().read(cx).snapshot(cx),
 817        );
 818        if self.active_match_index != new_index {
 819            self.active_match_index = new_index;
 820            cx.notify();
 821        }
 822    }
 823
 824    pub fn has_matches(&self) -> bool {
 825        self.active_match_index.is_some()
 826    }
 827
 828    fn move_focus_to_results(pane: &mut Pane, _: &ToggleFocus, cx: &mut ViewContext<Pane>) {
 829        if let Some(search_view) = pane
 830            .active_item()
 831            .and_then(|item| item.downcast::<ProjectSearchView>())
 832        {
 833            search_view.update(cx, |search_view, cx| {
 834                if !search_view.results_editor.is_focused(cx)
 835                    && !search_view.model.read(cx).match_ranges.is_empty()
 836                {
 837                    return search_view.focus_results_editor(cx);
 838                }
 839            });
 840        }
 841
 842        cx.propagate_action();
 843    }
 844}
 845
 846impl Default for ProjectSearchBar {
 847    fn default() -> Self {
 848        Self::new()
 849    }
 850}
 851
 852impl ProjectSearchBar {
 853    pub fn new() -> Self {
 854        Self {
 855            active_project_search: Default::default(),
 856            subscription: Default::default(),
 857        }
 858    }
 859
 860    fn search(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
 861        if let Some(search_view) = self.active_project_search.as_ref() {
 862            search_view.update(cx, |search_view, cx| search_view.search(cx));
 863        }
 864    }
 865
 866    fn search_in_new(workspace: &mut Workspace, _: &SearchInNew, cx: &mut ViewContext<Workspace>) {
 867        if let Some(search_view) = workspace
 868            .active_item(cx)
 869            .and_then(|item| item.downcast::<ProjectSearchView>())
 870        {
 871            let new_query = search_view.update(cx, |search_view, cx| {
 872                let new_query = search_view.build_search_query(cx);
 873                if new_query.is_some() {
 874                    if let Some(old_query) = search_view.model.read(cx).active_query.clone() {
 875                        search_view.query_editor.update(cx, |editor, cx| {
 876                            editor.set_text(old_query.as_str(), cx);
 877                        });
 878                        search_view.search_options = SearchOptions::from_query(&old_query);
 879                    }
 880                }
 881                new_query
 882            });
 883            if let Some(new_query) = new_query {
 884                let model = cx.add_model(|cx| {
 885                    let mut model = ProjectSearch::new(workspace.project().clone(), cx);
 886                    model.search(new_query, cx);
 887                    model
 888                });
 889                workspace.add_item(
 890                    Box::new(cx.add_view(|cx| ProjectSearchView::new(model, cx))),
 891                    cx,
 892                );
 893            }
 894        }
 895    }
 896
 897    fn select_next_match(pane: &mut Pane, _: &SelectNextMatch, cx: &mut ViewContext<Pane>) {
 898        if let Some(search_view) = pane
 899            .active_item()
 900            .and_then(|item| item.downcast::<ProjectSearchView>())
 901        {
 902            search_view.update(cx, |view, cx| view.select_match(Direction::Next, cx));
 903        } else {
 904            cx.propagate_action();
 905        }
 906    }
 907
 908    fn select_prev_match(pane: &mut Pane, _: &SelectPrevMatch, cx: &mut ViewContext<Pane>) {
 909        if let Some(search_view) = pane
 910            .active_item()
 911            .and_then(|item| item.downcast::<ProjectSearchView>())
 912        {
 913            search_view.update(cx, |view, cx| view.select_match(Direction::Prev, cx));
 914        } else {
 915            cx.propagate_action();
 916        }
 917    }
 918
 919    fn tab(&mut self, _: &editor::Tab, cx: &mut ViewContext<Self>) {
 920        self.cycle_field(Direction::Next, cx);
 921    }
 922
 923    fn tab_previous(&mut self, _: &editor::TabPrev, cx: &mut ViewContext<Self>) {
 924        self.cycle_field(Direction::Prev, cx);
 925    }
 926
 927    fn cycle_field(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
 928        let active_project_search = match &self.active_project_search {
 929            Some(active_project_search) => active_project_search,
 930
 931            None => {
 932                cx.propagate_action();
 933                return;
 934            }
 935        };
 936
 937        active_project_search.update(cx, |project_view, cx| {
 938            let views = &[
 939                &project_view.query_editor,
 940                &project_view.included_files_editor,
 941                &project_view.excluded_files_editor,
 942            ];
 943
 944            let current_index = match views
 945                .iter()
 946                .enumerate()
 947                .find(|(_, view)| view.is_focused(cx))
 948            {
 949                Some((index, _)) => index,
 950
 951                None => {
 952                    cx.propagate_action();
 953                    return;
 954                }
 955            };
 956
 957            let new_index = match direction {
 958                Direction::Next => (current_index + 1) % views.len(),
 959                Direction::Prev if current_index == 0 => views.len() - 1,
 960                Direction::Prev => (current_index - 1) % views.len(),
 961            };
 962            cx.focus(views[new_index]);
 963        });
 964    }
 965
 966    fn toggle_search_option(&mut self, option: SearchOptions, cx: &mut ViewContext<Self>) -> bool {
 967        if let Some(search_view) = self.active_project_search.as_ref() {
 968            search_view.update(cx, |search_view, cx| {
 969                search_view.search_options.toggle(option);
 970                search_view.semantic = None;
 971                search_view.search(cx);
 972            });
 973            cx.notify();
 974            true
 975        } else {
 976            false
 977        }
 978    }
 979
 980    fn toggle_semantic_search(&mut self, cx: &mut ViewContext<Self>) -> bool {
 981        if let Some(search_view) = self.active_project_search.as_ref() {
 982            search_view.update(cx, |search_view, cx| {
 983                if search_view.semantic.is_some() {
 984                    search_view.semantic = None;
 985                } else if let Some(semantic_index) = SemanticIndex::global(cx) {
 986                    // TODO: confirm that it's ok to send this project
 987                    search_view.search_options = SearchOptions::none();
 988
 989                    let project = search_view.model.read(cx).project.clone();
 990                    let index_task = semantic_index.update(cx, |semantic_index, cx| {
 991                        semantic_index.index_project(project, cx)
 992                    });
 993
 994                    cx.spawn(|search_view, mut cx| async move {
 995                        let (files_to_index, mut files_remaining_rx) = index_task.await?;
 996
 997                        search_view.update(&mut cx, |search_view, cx| {
 998                            cx.notify();
 999                            search_view.semantic = Some(SemanticSearchState {
1000                                file_count: files_to_index,
1001                                outstanding_file_count: files_to_index,
1002                                _progress_task: cx.spawn(|search_view, mut cx| async move {
1003                                    while let Some(count) = files_remaining_rx.recv().await {
1004                                        search_view
1005                                            .update(&mut cx, |search_view, cx| {
1006                                                if let Some(semantic_search_state) =
1007                                                    &mut search_view.semantic
1008                                                {
1009                                                    semantic_search_state.outstanding_file_count =
1010                                                        count;
1011                                                    cx.notify();
1012                                                    if count == 0 {
1013                                                        return;
1014                                                    }
1015                                                }
1016                                            })
1017                                            .ok();
1018                                    }
1019                                }),
1020                            });
1021                        })?;
1022                        anyhow::Ok(())
1023                    })
1024                    .detach_and_log_err(cx);
1025                }
1026                cx.notify();
1027            });
1028            cx.notify();
1029            true
1030        } else {
1031            false
1032        }
1033    }
1034
1035    fn render_nav_button(
1036        &self,
1037        icon: &'static str,
1038        direction: Direction,
1039        cx: &mut ViewContext<Self>,
1040    ) -> AnyElement<Self> {
1041        let action: Box<dyn Action>;
1042        let tooltip;
1043        match direction {
1044            Direction::Prev => {
1045                action = Box::new(SelectPrevMatch);
1046                tooltip = "Select Previous Match";
1047            }
1048            Direction::Next => {
1049                action = Box::new(SelectNextMatch);
1050                tooltip = "Select Next Match";
1051            }
1052        };
1053        let tooltip_style = theme::current(cx).tooltip.clone();
1054
1055        enum NavButton {}
1056        MouseEventHandler::<NavButton, _>::new(direction as usize, cx, |state, cx| {
1057            let theme = theme::current(cx);
1058            let style = theme.search.option_button.inactive_state().style_for(state);
1059            Label::new(icon, style.text.clone())
1060                .contained()
1061                .with_style(style.container)
1062        })
1063        .on_click(MouseButton::Left, move |_, this, cx| {
1064            if let Some(search) = this.active_project_search.as_ref() {
1065                search.update(cx, |search, cx| search.select_match(direction, cx));
1066            }
1067        })
1068        .with_cursor_style(CursorStyle::PointingHand)
1069        .with_tooltip::<NavButton>(
1070            direction as usize,
1071            tooltip.to_string(),
1072            Some(action),
1073            tooltip_style,
1074            cx,
1075        )
1076        .into_any()
1077    }
1078
1079    fn render_option_button(
1080        &self,
1081        icon: &'static str,
1082        option: SearchOptions,
1083        cx: &mut ViewContext<Self>,
1084    ) -> AnyElement<Self> {
1085        let tooltip_style = theme::current(cx).tooltip.clone();
1086        let is_active = self.is_option_enabled(option, cx);
1087        MouseEventHandler::<Self, _>::new(option.bits as usize, cx, |state, cx| {
1088            let theme = theme::current(cx);
1089            let style = theme
1090                .search
1091                .option_button
1092                .in_state(is_active)
1093                .style_for(state);
1094            Label::new(icon, style.text.clone())
1095                .contained()
1096                .with_style(style.container)
1097        })
1098        .on_click(MouseButton::Left, move |_, this, cx| {
1099            this.toggle_search_option(option, cx);
1100        })
1101        .with_cursor_style(CursorStyle::PointingHand)
1102        .with_tooltip::<Self>(
1103            option.bits as usize,
1104            format!("Toggle {}", option.label()),
1105            Some(option.to_toggle_action()),
1106            tooltip_style,
1107            cx,
1108        )
1109        .into_any()
1110    }
1111
1112    fn render_semantic_search_button(&self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
1113        let tooltip_style = theme::current(cx).tooltip.clone();
1114        let is_active = if let Some(search) = self.active_project_search.as_ref() {
1115            let search = search.read(cx);
1116            search.semantic.is_some()
1117        } else {
1118            false
1119        };
1120
1121        let region_id = 3;
1122
1123        MouseEventHandler::<Self, _>::new(region_id, cx, |state, cx| {
1124            let theme = theme::current(cx);
1125            let style = theme
1126                .search
1127                .option_button
1128                .in_state(is_active)
1129                .style_for(state);
1130            Label::new("Semantic", style.text.clone())
1131                .contained()
1132                .with_style(style.container)
1133        })
1134        .on_click(MouseButton::Left, move |_, this, cx| {
1135            this.toggle_semantic_search(cx);
1136        })
1137        .with_cursor_style(CursorStyle::PointingHand)
1138        .with_tooltip::<Self>(
1139            region_id,
1140            format!("Toggle Semantic Search"),
1141            Some(Box::new(ToggleSemanticSearch)),
1142            tooltip_style,
1143            cx,
1144        )
1145        .into_any()
1146    }
1147
1148    fn is_option_enabled(&self, option: SearchOptions, cx: &AppContext) -> bool {
1149        if let Some(search) = self.active_project_search.as_ref() {
1150            search.read(cx).search_options.contains(option)
1151        } else {
1152            false
1153        }
1154    }
1155}
1156
1157impl Entity for ProjectSearchBar {
1158    type Event = ();
1159}
1160
1161impl View for ProjectSearchBar {
1162    fn ui_name() -> &'static str {
1163        "ProjectSearchBar"
1164    }
1165
1166    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
1167        if let Some(search) = self.active_project_search.as_ref() {
1168            let search = search.read(cx);
1169            let theme = theme::current(cx).clone();
1170            let query_container_style = if search.panels_with_errors.contains(&InputPanel::Query) {
1171                theme.search.invalid_editor
1172            } else {
1173                theme.search.editor.input.container
1174            };
1175            let include_container_style =
1176                if search.panels_with_errors.contains(&InputPanel::Include) {
1177                    theme.search.invalid_include_exclude_editor
1178                } else {
1179                    theme.search.include_exclude_editor.input.container
1180                };
1181            let exclude_container_style =
1182                if search.panels_with_errors.contains(&InputPanel::Exclude) {
1183                    theme.search.invalid_include_exclude_editor
1184                } else {
1185                    theme.search.include_exclude_editor.input.container
1186                };
1187
1188            let included_files_view = ChildView::new(&search.included_files_editor, cx)
1189                .aligned()
1190                .left()
1191                .flex(1.0, true);
1192            let excluded_files_view = ChildView::new(&search.excluded_files_editor, cx)
1193                .aligned()
1194                .right()
1195                .flex(1.0, true);
1196
1197            let row_spacing = theme.workspace.toolbar.container.padding.bottom;
1198
1199            Flex::column()
1200                .with_child(
1201                    Flex::row()
1202                        .with_child(
1203                            Flex::row()
1204                                .with_child(
1205                                    ChildView::new(&search.query_editor, cx)
1206                                        .aligned()
1207                                        .left()
1208                                        .flex(1., true),
1209                                )
1210                                .with_children(search.active_match_index.map(|match_ix| {
1211                                    Label::new(
1212                                        format!(
1213                                            "{}/{}",
1214                                            match_ix + 1,
1215                                            search.model.read(cx).match_ranges.len()
1216                                        ),
1217                                        theme.search.match_index.text.clone(),
1218                                    )
1219                                    .contained()
1220                                    .with_style(theme.search.match_index.container)
1221                                    .aligned()
1222                                }))
1223                                .contained()
1224                                .with_style(query_container_style)
1225                                .aligned()
1226                                .constrained()
1227                                .with_min_width(theme.search.editor.min_width)
1228                                .with_max_width(theme.search.editor.max_width)
1229                                .flex(1., false),
1230                        )
1231                        .with_child(
1232                            Flex::row()
1233                                .with_child(self.render_nav_button("<", Direction::Prev, cx))
1234                                .with_child(self.render_nav_button(">", Direction::Next, cx))
1235                                .aligned(),
1236                        )
1237                        .with_child({
1238                            let row = if SemanticIndex::enabled(cx) {
1239                                Flex::row().with_child(self.render_semantic_search_button(cx))
1240                            } else {
1241                                Flex::row()
1242                            };
1243
1244                            let row = row
1245                                .with_child(self.render_option_button(
1246                                    "Case",
1247                                    SearchOptions::CASE_SENSITIVE,
1248                                    cx,
1249                                ))
1250                                .with_child(self.render_option_button(
1251                                    "Word",
1252                                    SearchOptions::WHOLE_WORD,
1253                                    cx,
1254                                ))
1255                                .with_child(self.render_option_button(
1256                                    "Regex",
1257                                    SearchOptions::REGEX,
1258                                    cx,
1259                                ))
1260                                .contained()
1261                                .with_style(theme.search.option_button_group)
1262                                .aligned();
1263
1264                            row
1265                        })
1266                        .contained()
1267                        .with_margin_bottom(row_spacing),
1268                )
1269                .with_child(
1270                    Flex::row()
1271                        .with_child(
1272                            Flex::row()
1273                                .with_child(included_files_view)
1274                                .contained()
1275                                .with_style(include_container_style)
1276                                .aligned()
1277                                .constrained()
1278                                .with_min_width(theme.search.include_exclude_editor.min_width)
1279                                .with_max_width(theme.search.include_exclude_editor.max_width)
1280                                .flex(1., false),
1281                        )
1282                        .with_child(
1283                            Flex::row()
1284                                .with_child(excluded_files_view)
1285                                .contained()
1286                                .with_style(exclude_container_style)
1287                                .aligned()
1288                                .constrained()
1289                                .with_min_width(theme.search.include_exclude_editor.min_width)
1290                                .with_max_width(theme.search.include_exclude_editor.max_width)
1291                                .flex(1., false),
1292                        ),
1293                )
1294                .contained()
1295                .with_style(theme.search.container)
1296                .aligned()
1297                .left()
1298                .into_any_named("project search")
1299        } else {
1300            Empty::new().into_any()
1301        }
1302    }
1303}
1304
1305impl ToolbarItemView for ProjectSearchBar {
1306    fn set_active_pane_item(
1307        &mut self,
1308        active_pane_item: Option<&dyn ItemHandle>,
1309        cx: &mut ViewContext<Self>,
1310    ) -> ToolbarItemLocation {
1311        cx.notify();
1312        self.subscription = None;
1313        self.active_project_search = None;
1314        if let Some(search) = active_pane_item.and_then(|i| i.downcast::<ProjectSearchView>()) {
1315            self.subscription = Some(cx.observe(&search, |_, _, cx| cx.notify()));
1316            self.active_project_search = Some(search);
1317            ToolbarItemLocation::PrimaryLeft {
1318                flex: Some((1., false)),
1319            }
1320        } else {
1321            ToolbarItemLocation::Hidden
1322        }
1323    }
1324
1325    fn row_count(&self) -> usize {
1326        2
1327    }
1328}
1329
1330#[cfg(test)]
1331pub mod tests {
1332    use super::*;
1333    use editor::DisplayPoint;
1334    use gpui::{color::Color, executor::Deterministic, TestAppContext};
1335    use project::FakeFs;
1336    use serde_json::json;
1337    use settings::SettingsStore;
1338    use std::sync::Arc;
1339    use theme::ThemeSettings;
1340
1341    #[gpui::test]
1342    async fn test_project_search(deterministic: Arc<Deterministic>, cx: &mut TestAppContext) {
1343        init_test(cx);
1344
1345        let fs = FakeFs::new(cx.background());
1346        fs.insert_tree(
1347            "/dir",
1348            json!({
1349                "one.rs": "const ONE: usize = 1;",
1350                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
1351                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
1352                "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
1353            }),
1354        )
1355        .await;
1356        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
1357        let search = cx.add_model(|cx| ProjectSearch::new(project, cx));
1358        let (_, search_view) = cx.add_window(|cx| ProjectSearchView::new(search.clone(), cx));
1359
1360        search_view.update(cx, |search_view, cx| {
1361            search_view
1362                .query_editor
1363                .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
1364            search_view.search(cx);
1365        });
1366        deterministic.run_until_parked();
1367        search_view.update(cx, |search_view, cx| {
1368            assert_eq!(
1369                search_view
1370                    .results_editor
1371                    .update(cx, |editor, cx| editor.display_text(cx)),
1372                "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;"
1373            );
1374            assert_eq!(
1375                search_view
1376                    .results_editor
1377                    .update(cx, |editor, cx| editor.all_background_highlights(cx)),
1378                &[
1379                    (
1380                        DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35),
1381                        Color::red()
1382                    ),
1383                    (
1384                        DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40),
1385                        Color::red()
1386                    ),
1387                    (
1388                        DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9),
1389                        Color::red()
1390                    )
1391                ]
1392            );
1393            assert_eq!(search_view.active_match_index, Some(0));
1394            assert_eq!(
1395                search_view
1396                    .results_editor
1397                    .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1398                [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
1399            );
1400
1401            search_view.select_match(Direction::Next, cx);
1402        });
1403
1404        search_view.update(cx, |search_view, cx| {
1405            assert_eq!(search_view.active_match_index, Some(1));
1406            assert_eq!(
1407                search_view
1408                    .results_editor
1409                    .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1410                [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
1411            );
1412            search_view.select_match(Direction::Next, cx);
1413        });
1414
1415        search_view.update(cx, |search_view, cx| {
1416            assert_eq!(search_view.active_match_index, Some(2));
1417            assert_eq!(
1418                search_view
1419                    .results_editor
1420                    .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1421                [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
1422            );
1423            search_view.select_match(Direction::Next, cx);
1424        });
1425
1426        search_view.update(cx, |search_view, cx| {
1427            assert_eq!(search_view.active_match_index, Some(0));
1428            assert_eq!(
1429                search_view
1430                    .results_editor
1431                    .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1432                [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
1433            );
1434            search_view.select_match(Direction::Prev, cx);
1435        });
1436
1437        search_view.update(cx, |search_view, cx| {
1438            assert_eq!(search_view.active_match_index, Some(2));
1439            assert_eq!(
1440                search_view
1441                    .results_editor
1442                    .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1443                [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
1444            );
1445            search_view.select_match(Direction::Prev, cx);
1446        });
1447
1448        search_view.update(cx, |search_view, cx| {
1449            assert_eq!(search_view.active_match_index, Some(1));
1450            assert_eq!(
1451                search_view
1452                    .results_editor
1453                    .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1454                [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
1455            );
1456        });
1457    }
1458
1459    #[gpui::test]
1460    async fn test_project_search_focus(deterministic: Arc<Deterministic>, cx: &mut TestAppContext) {
1461        init_test(cx);
1462
1463        let fs = FakeFs::new(cx.background());
1464        fs.insert_tree(
1465            "/dir",
1466            json!({
1467                "one.rs": "const ONE: usize = 1;",
1468                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
1469                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
1470                "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
1471            }),
1472        )
1473        .await;
1474        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
1475        let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
1476
1477        let active_item = cx.read(|cx| {
1478            workspace
1479                .read(cx)
1480                .active_pane()
1481                .read(cx)
1482                .active_item()
1483                .and_then(|item| item.downcast::<ProjectSearchView>())
1484        });
1485        assert!(
1486            active_item.is_none(),
1487            "Expected no search panel to be active, but got: {active_item:?}"
1488        );
1489
1490        workspace.update(cx, |workspace, cx| {
1491            ProjectSearchView::deploy(workspace, &workspace::NewSearch, cx)
1492        });
1493
1494        let Some(search_view) = cx.read(|cx| {
1495            workspace
1496                .read(cx)
1497                .active_pane()
1498                .read(cx)
1499                .active_item()
1500                .and_then(|item| item.downcast::<ProjectSearchView>())
1501        }) else {
1502            panic!("Search view expected to appear after new search event trigger")
1503        };
1504        let search_view_id = search_view.id();
1505
1506        cx.spawn(
1507            |mut cx| async move { cx.dispatch_action(window_id, search_view_id, &ToggleFocus) },
1508        )
1509        .detach();
1510        deterministic.run_until_parked();
1511        search_view.update(cx, |search_view, cx| {
1512            assert!(
1513                search_view.query_editor.is_focused(cx),
1514                "Empty search view should be focused after the toggle focus event: no results panel to focus on",
1515            );
1516        });
1517
1518        search_view.update(cx, |search_view, cx| {
1519            let query_editor = &search_view.query_editor;
1520            assert!(
1521                query_editor.is_focused(cx),
1522                "Search view should be focused after the new search view is activated",
1523            );
1524            let query_text = query_editor.read(cx).text(cx);
1525            assert!(
1526                query_text.is_empty(),
1527                "New search query should be empty but got '{query_text}'",
1528            );
1529            let results_text = search_view
1530                .results_editor
1531                .update(cx, |editor, cx| editor.display_text(cx));
1532            assert!(
1533                results_text.is_empty(),
1534                "Empty search view should have no results but got '{results_text}'"
1535            );
1536        });
1537
1538        search_view.update(cx, |search_view, cx| {
1539            search_view.query_editor.update(cx, |query_editor, cx| {
1540                query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", cx)
1541            });
1542            search_view.search(cx);
1543        });
1544        deterministic.run_until_parked();
1545        search_view.update(cx, |search_view, cx| {
1546            let results_text = search_view
1547                .results_editor
1548                .update(cx, |editor, cx| editor.display_text(cx));
1549            assert!(
1550                results_text.is_empty(),
1551                "Search view for mismatching query should have no results but got '{results_text}'"
1552            );
1553            assert!(
1554                search_view.query_editor.is_focused(cx),
1555                "Search view should be focused after mismatching query had been used in search",
1556            );
1557        });
1558        cx.spawn(
1559            |mut cx| async move { cx.dispatch_action(window_id, search_view_id, &ToggleFocus) },
1560        )
1561        .detach();
1562        deterministic.run_until_parked();
1563        search_view.update(cx, |search_view, cx| {
1564            assert!(
1565                search_view.query_editor.is_focused(cx),
1566                "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
1567            );
1568        });
1569
1570        search_view.update(cx, |search_view, cx| {
1571            search_view
1572                .query_editor
1573                .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
1574            search_view.search(cx);
1575        });
1576        deterministic.run_until_parked();
1577        search_view.update(cx, |search_view, cx| {
1578            assert_eq!(
1579                search_view
1580                    .results_editor
1581                    .update(cx, |editor, cx| editor.display_text(cx)),
1582                "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
1583                "Search view results should match the query"
1584            );
1585            assert!(
1586                search_view.results_editor.is_focused(cx),
1587                "Search view with mismatching query should be focused after search results are available",
1588            );
1589        });
1590        cx.spawn(
1591            |mut cx| async move { cx.dispatch_action(window_id, search_view_id, &ToggleFocus) },
1592        )
1593        .detach();
1594        deterministic.run_until_parked();
1595        search_view.update(cx, |search_view, cx| {
1596            assert!(
1597                search_view.results_editor.is_focused(cx),
1598                "Search view with matching query should still have its results editor focused after the toggle focus event",
1599            );
1600        });
1601
1602        workspace.update(cx, |workspace, cx| {
1603            ProjectSearchView::deploy(workspace, &workspace::NewSearch, cx)
1604        });
1605        search_view.update(cx, |search_view, cx| {
1606            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");
1607            assert_eq!(
1608                search_view
1609                    .results_editor
1610                    .update(cx, |editor, cx| editor.display_text(cx)),
1611                "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
1612                "Results should be unchanged after search view 2nd open in a row"
1613            );
1614            assert!(
1615                search_view.query_editor.is_focused(cx),
1616                "Focus should be moved into query editor again after search view 2nd open in a row"
1617            );
1618        });
1619
1620        cx.spawn(
1621            |mut cx| async move { cx.dispatch_action(window_id, search_view_id, &ToggleFocus) },
1622        )
1623        .detach();
1624        deterministic.run_until_parked();
1625        search_view.update(cx, |search_view, cx| {
1626            assert!(
1627                search_view.results_editor.is_focused(cx),
1628                "Search view with matching query should switch focus to the results editor after the toggle focus event",
1629            );
1630        });
1631    }
1632
1633    #[gpui::test]
1634    async fn test_new_project_search_in_directory(
1635        deterministic: Arc<Deterministic>,
1636        cx: &mut TestAppContext,
1637    ) {
1638        init_test(cx);
1639
1640        let fs = FakeFs::new(cx.background());
1641        fs.insert_tree(
1642            "/dir",
1643            json!({
1644                "a": {
1645                    "one.rs": "const ONE: usize = 1;",
1646                    "two.rs": "const TWO: usize = one::ONE + one::ONE;",
1647                },
1648                "b": {
1649                    "three.rs": "const THREE: usize = one::ONE + two::TWO;",
1650                    "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
1651                },
1652            }),
1653        )
1654        .await;
1655        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
1656        let worktree_id = project.read_with(cx, |project, cx| {
1657            project.worktrees(cx).next().unwrap().read(cx).id()
1658        });
1659        let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
1660
1661        let active_item = cx.read(|cx| {
1662            workspace
1663                .read(cx)
1664                .active_pane()
1665                .read(cx)
1666                .active_item()
1667                .and_then(|item| item.downcast::<ProjectSearchView>())
1668        });
1669        assert!(
1670            active_item.is_none(),
1671            "Expected no search panel to be active, but got: {active_item:?}"
1672        );
1673
1674        let one_file_entry = cx.update(|cx| {
1675            workspace
1676                .read(cx)
1677                .project()
1678                .read(cx)
1679                .entry_for_path(&(worktree_id, "a/one.rs").into(), cx)
1680                .expect("no entry for /a/one.rs file")
1681        });
1682        assert!(one_file_entry.is_file());
1683        workspace.update(cx, |workspace, cx| {
1684            ProjectSearchView::new_search_in_directory(workspace, &one_file_entry, cx)
1685        });
1686        let active_search_entry = cx.read(|cx| {
1687            workspace
1688                .read(cx)
1689                .active_pane()
1690                .read(cx)
1691                .active_item()
1692                .and_then(|item| item.downcast::<ProjectSearchView>())
1693        });
1694        assert!(
1695            active_search_entry.is_none(),
1696            "Expected no search panel to be active for file entry"
1697        );
1698
1699        let a_dir_entry = cx.update(|cx| {
1700            workspace
1701                .read(cx)
1702                .project()
1703                .read(cx)
1704                .entry_for_path(&(worktree_id, "a").into(), cx)
1705                .expect("no entry for /a/ directory")
1706        });
1707        assert!(a_dir_entry.is_dir());
1708        workspace.update(cx, |workspace, cx| {
1709            ProjectSearchView::new_search_in_directory(workspace, &a_dir_entry, cx)
1710        });
1711
1712        let Some(search_view) = cx.read(|cx| {
1713            workspace
1714                .read(cx)
1715                .active_pane()
1716                .read(cx)
1717                .active_item()
1718                .and_then(|item| item.downcast::<ProjectSearchView>())
1719        }) else {
1720            panic!("Search view expected to appear after new search in directory event trigger")
1721        };
1722        deterministic.run_until_parked();
1723        search_view.update(cx, |search_view, cx| {
1724            assert!(
1725                search_view.query_editor.is_focused(cx),
1726                "On new search in directory, focus should be moved into query editor"
1727            );
1728            search_view.excluded_files_editor.update(cx, |editor, cx| {
1729                assert!(
1730                    editor.display_text(cx).is_empty(),
1731                    "New search in directory should not have any excluded files"
1732                );
1733            });
1734            search_view.included_files_editor.update(cx, |editor, cx| {
1735                assert_eq!(
1736                    editor.display_text(cx),
1737                    a_dir_entry.path.to_str().unwrap(),
1738                    "New search in directory should have included dir entry path"
1739                );
1740            });
1741        });
1742
1743        search_view.update(cx, |search_view, cx| {
1744            search_view
1745                .query_editor
1746                .update(cx, |query_editor, cx| query_editor.set_text("const", cx));
1747            search_view.search(cx);
1748        });
1749        deterministic.run_until_parked();
1750        search_view.update(cx, |search_view, cx| {
1751            assert_eq!(
1752                search_view
1753                    .results_editor
1754                    .update(cx, |editor, cx| editor.display_text(cx)),
1755                "\n\nconst ONE: usize = 1;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
1756                "New search in directory should have a filter that matches a certain directory"
1757            );
1758        });
1759    }
1760
1761    pub fn init_test(cx: &mut TestAppContext) {
1762        cx.foreground().forbid_parking();
1763        let fonts = cx.font_cache();
1764        let mut theme = gpui::fonts::with_font_cache(fonts.clone(), theme::Theme::default);
1765        theme.search.match_background = Color::red();
1766
1767        cx.update(|cx| {
1768            cx.set_global(SettingsStore::test(cx));
1769            cx.set_global(ActiveSearches::default());
1770
1771            theme::init((), cx);
1772            cx.update_global::<SettingsStore, _, _>(|store, _| {
1773                let mut settings = store.get::<ThemeSettings>(None).clone();
1774                settings.theme = Arc::new(theme);
1775                store.override_global(settings)
1776            });
1777
1778            language::init(cx);
1779            client::init_settings(cx);
1780            editor::init(cx);
1781            workspace::init_settings(cx);
1782            Project::init_settings(cx);
1783            super::init(cx);
1784        });
1785    }
1786}