project_search.rs

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