project_search.rs

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