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_fallbacks: settings.buffer_font.fallbacks.clone(),
1342            font_size: rems(0.875).into(),
1343            font_weight: settings.buffer_font.weight,
1344            line_height: relative(1.3),
1345            ..Default::default()
1346        };
1347
1348        EditorElement::new(
1349            &editor,
1350            EditorStyle {
1351                background: cx.theme().colors().editor_background,
1352                local_player: cx.theme().players().local(),
1353                text: text_style,
1354                ..Default::default()
1355            },
1356        )
1357    }
1358}
1359
1360impl Render for ProjectSearchBar {
1361    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1362        let Some(search) = self.active_project_search.clone() else {
1363            return div();
1364        };
1365        let search = search.read(cx);
1366
1367        let query_column = h_flex()
1368            .flex_1()
1369            .h_8()
1370            .mr_2()
1371            .px_2()
1372            .py_1()
1373            .border_1()
1374            .border_color(search.border_color_for(InputPanel::Query, cx))
1375            .rounded_lg()
1376            .min_w(rems(MIN_INPUT_WIDTH_REMS))
1377            .max_w(rems(MAX_INPUT_WIDTH_REMS))
1378            .on_action(cx.listener(|this, action, cx| this.confirm(action, cx)))
1379            .on_action(cx.listener(|this, action, cx| this.previous_history_query(action, cx)))
1380            .on_action(cx.listener(|this, action, cx| this.next_history_query(action, cx)))
1381            .child(self.render_text_input(&search.query_editor, cx))
1382            .child(
1383                h_flex()
1384                    .child(SearchOptions::CASE_SENSITIVE.as_button(
1385                        self.is_option_enabled(SearchOptions::CASE_SENSITIVE, cx),
1386                        cx.listener(|this, _, cx| {
1387                            this.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
1388                        }),
1389                    ))
1390                    .child(SearchOptions::WHOLE_WORD.as_button(
1391                        self.is_option_enabled(SearchOptions::WHOLE_WORD, cx),
1392                        cx.listener(|this, _, cx| {
1393                            this.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
1394                        }),
1395                    ))
1396                    .child(SearchOptions::REGEX.as_button(
1397                        self.is_option_enabled(SearchOptions::REGEX, cx),
1398                        cx.listener(|this, _, cx| {
1399                            this.toggle_search_option(SearchOptions::REGEX, cx);
1400                        }),
1401                    )),
1402            );
1403
1404        let mode_column = v_flex().items_start().justify_start().child(
1405            h_flex()
1406                .child(
1407                    IconButton::new("project-search-filter-button", IconName::Filter)
1408                        .tooltip(|cx| Tooltip::for_action("Toggle filters", &ToggleFilters, cx))
1409                        .on_click(cx.listener(|this, _, cx| {
1410                            this.toggle_filters(cx);
1411                        }))
1412                        .selected(
1413                            self.active_project_search
1414                                .as_ref()
1415                                .map(|search| search.read(cx).filters_enabled)
1416                                .unwrap_or_default(),
1417                        )
1418                        .tooltip(|cx| Tooltip::for_action("Toggle filters", &ToggleFilters, cx)),
1419                )
1420                .child(
1421                    IconButton::new("project-search-toggle-replace", IconName::Replace)
1422                        .on_click(cx.listener(|this, _, cx| {
1423                            this.toggle_replace(&ToggleReplace, cx);
1424                        }))
1425                        .selected(
1426                            self.active_project_search
1427                                .as_ref()
1428                                .map(|search| search.read(cx).replace_enabled)
1429                                .unwrap_or_default(),
1430                        )
1431                        .tooltip(|cx| Tooltip::for_action("Toggle replace", &ToggleReplace, cx)),
1432                ),
1433        );
1434
1435        let limit_reached = search.model.read(cx).limit_reached;
1436        let match_text = search
1437            .active_match_index
1438            .and_then(|index| {
1439                let index = index + 1;
1440                let match_quantity = search.model.read(cx).match_ranges.len();
1441                if match_quantity > 0 {
1442                    debug_assert!(match_quantity >= index);
1443                    if limit_reached {
1444                        Some(format!("{index}/{match_quantity}+").to_string())
1445                    } else {
1446                        Some(format!("{index}/{match_quantity}").to_string())
1447                    }
1448                } else {
1449                    None
1450                }
1451            })
1452            .unwrap_or_else(|| "0/0".to_string());
1453
1454        let matches_column = h_flex()
1455            .child(
1456                IconButton::new("project-search-prev-match", IconName::ChevronLeft)
1457                    .disabled(search.active_match_index.is_none())
1458                    .on_click(cx.listener(|this, _, cx| {
1459                        if let Some(search) = this.active_project_search.as_ref() {
1460                            search.update(cx, |this, cx| {
1461                                this.select_match(Direction::Prev, cx);
1462                            })
1463                        }
1464                    }))
1465                    .tooltip(|cx| {
1466                        Tooltip::for_action("Go to previous match", &SelectPrevMatch, cx)
1467                    }),
1468            )
1469            .child(
1470                IconButton::new("project-search-next-match", IconName::ChevronRight)
1471                    .disabled(search.active_match_index.is_none())
1472                    .on_click(cx.listener(|this, _, cx| {
1473                        if let Some(search) = this.active_project_search.as_ref() {
1474                            search.update(cx, |this, cx| {
1475                                this.select_match(Direction::Next, cx);
1476                            })
1477                        }
1478                    }))
1479                    .tooltip(|cx| Tooltip::for_action("Go to next match", &SelectNextMatch, cx)),
1480            )
1481            .child(
1482                h_flex()
1483                    .id("matches")
1484                    .min_w(rems_from_px(40.))
1485                    .child(
1486                        Label::new(match_text).color(if search.active_match_index.is_some() {
1487                            Color::Default
1488                        } else {
1489                            Color::Disabled
1490                        }),
1491                    )
1492                    .when(limit_reached, |el| {
1493                        el.tooltip(|cx| {
1494                            Tooltip::text("Search limits reached.\nTry narrowing your search.", cx)
1495                        })
1496                    }),
1497            );
1498
1499        let search_line = h_flex()
1500            .flex_1()
1501            .child(query_column)
1502            .child(mode_column)
1503            .child(matches_column);
1504
1505        let replace_line = search.replace_enabled.then(|| {
1506            let replace_column = h_flex()
1507                .flex_1()
1508                .min_w(rems(MIN_INPUT_WIDTH_REMS))
1509                .max_w(rems(MAX_INPUT_WIDTH_REMS))
1510                .h_8()
1511                .px_2()
1512                .py_1()
1513                .border_1()
1514                .border_color(cx.theme().colors().border)
1515                .rounded_lg()
1516                .child(self.render_text_input(&search.replacement_editor, cx));
1517            let replace_actions = h_flex().when(search.replace_enabled, |this| {
1518                this.child(
1519                    IconButton::new("project-search-replace-next", IconName::ReplaceNext)
1520                        .on_click(cx.listener(|this, _, cx| {
1521                            if let Some(search) = this.active_project_search.as_ref() {
1522                                search.update(cx, |this, cx| {
1523                                    this.replace_next(&ReplaceNext, cx);
1524                                })
1525                            }
1526                        }))
1527                        .tooltip(|cx| Tooltip::for_action("Replace next match", &ReplaceNext, cx)),
1528                )
1529                .child(
1530                    IconButton::new("project-search-replace-all", IconName::ReplaceAll)
1531                        .on_click(cx.listener(|this, _, cx| {
1532                            if let Some(search) = this.active_project_search.as_ref() {
1533                                search.update(cx, |this, cx| {
1534                                    this.replace_all(&ReplaceAll, cx);
1535                                })
1536                            }
1537                        }))
1538                        .tooltip(|cx| Tooltip::for_action("Replace all matches", &ReplaceAll, cx)),
1539                )
1540            });
1541            h_flex()
1542                .pr(rems(5.5))
1543                .gap_2()
1544                .child(replace_column)
1545                .child(replace_actions)
1546        });
1547
1548        let filter_line = search.filters_enabled.then(|| {
1549            h_flex()
1550                .w_full()
1551                .gap_2()
1552                .child(
1553                    h_flex()
1554                        .flex_1()
1555                        // chosen so the total width of the search bar line
1556                        // is about the same as the include/exclude line
1557                        .min_w(rems(10.25))
1558                        .max_w(rems(20.))
1559                        .h_8()
1560                        .px_2()
1561                        .py_1()
1562                        .border_1()
1563                        .border_color(search.border_color_for(InputPanel::Include, cx))
1564                        .rounded_lg()
1565                        .child(self.render_text_input(&search.included_files_editor, cx)),
1566                )
1567                .child(
1568                    h_flex()
1569                        .flex_1()
1570                        .min_w(rems(10.25))
1571                        .max_w(rems(20.))
1572                        .h_8()
1573                        .px_2()
1574                        .py_1()
1575                        .border_1()
1576                        .border_color(search.border_color_for(InputPanel::Exclude, cx))
1577                        .rounded_lg()
1578                        .child(self.render_text_input(&search.excluded_files_editor, cx)),
1579                )
1580                .child(
1581                    SearchOptions::INCLUDE_IGNORED.as_button(
1582                        search
1583                            .search_options
1584                            .contains(SearchOptions::INCLUDE_IGNORED),
1585                        cx.listener(|this, _, cx| {
1586                            this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, cx);
1587                        }),
1588                    ),
1589                )
1590        });
1591        let mut key_context = KeyContext::default();
1592        key_context.add("ProjectSearchBar");
1593        if search.replacement_editor.focus_handle(cx).is_focused(cx) {
1594            key_context.add("in_replace");
1595        }
1596
1597        v_flex()
1598            .key_context(key_context)
1599            .on_action(cx.listener(|this, _: &ToggleFocus, cx| this.move_focus_to_results(cx)))
1600            .on_action(cx.listener(|this, _: &ToggleFilters, cx| {
1601                this.toggle_filters(cx);
1602            }))
1603            .capture_action(cx.listener(|this, action, cx| {
1604                this.tab(action, cx);
1605                cx.stop_propagation();
1606            }))
1607            .capture_action(cx.listener(|this, action, cx| {
1608                this.tab_previous(action, cx);
1609                cx.stop_propagation();
1610            }))
1611            .on_action(cx.listener(|this, action, cx| this.confirm(action, cx)))
1612            .on_action(cx.listener(|this, action, cx| {
1613                this.toggle_replace(action, cx);
1614            }))
1615            .on_action(cx.listener(|this, _: &ToggleWholeWord, cx| {
1616                this.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
1617            }))
1618            .on_action(cx.listener(|this, _: &ToggleCaseSensitive, cx| {
1619                this.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
1620            }))
1621            .on_action(cx.listener(|this, action, cx| {
1622                if let Some(search) = this.active_project_search.as_ref() {
1623                    search.update(cx, |this, cx| {
1624                        this.replace_next(action, cx);
1625                    })
1626                }
1627            }))
1628            .on_action(cx.listener(|this, action, cx| {
1629                if let Some(search) = this.active_project_search.as_ref() {
1630                    search.update(cx, |this, cx| {
1631                        this.replace_all(action, cx);
1632                    })
1633                }
1634            }))
1635            .when(search.filters_enabled, |this| {
1636                this.on_action(cx.listener(|this, _: &ToggleIncludeIgnored, cx| {
1637                    this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, cx);
1638                }))
1639            })
1640            .on_action(cx.listener(Self::select_next_match))
1641            .on_action(cx.listener(Self::select_prev_match))
1642            .gap_2()
1643            .w_full()
1644            .child(search_line)
1645            .children(replace_line)
1646            .children(filter_line)
1647    }
1648}
1649
1650impl EventEmitter<ToolbarItemEvent> for ProjectSearchBar {}
1651
1652impl ToolbarItemView for ProjectSearchBar {
1653    fn set_active_pane_item(
1654        &mut self,
1655        active_pane_item: Option<&dyn ItemHandle>,
1656        cx: &mut ViewContext<Self>,
1657    ) -> ToolbarItemLocation {
1658        cx.notify();
1659        self.subscription = None;
1660        self.active_project_search = None;
1661        if let Some(search) = active_pane_item.and_then(|i| i.downcast::<ProjectSearchView>()) {
1662            self.subscription = Some(cx.observe(&search, |_, _, cx| cx.notify()));
1663            self.active_project_search = Some(search);
1664            ToolbarItemLocation::PrimaryLeft {}
1665        } else {
1666            ToolbarItemLocation::Hidden
1667        }
1668    }
1669}
1670
1671fn register_workspace_action<A: Action>(
1672    workspace: &mut Workspace,
1673    callback: fn(&mut ProjectSearchBar, &A, &mut ViewContext<ProjectSearchBar>),
1674) {
1675    workspace.register_action(move |workspace, action: &A, cx| {
1676        if workspace.has_active_modal(cx) {
1677            cx.propagate();
1678            return;
1679        }
1680
1681        workspace.active_pane().update(cx, |pane, cx| {
1682            pane.toolbar().update(cx, move |workspace, cx| {
1683                if let Some(search_bar) = workspace.item_of_type::<ProjectSearchBar>() {
1684                    search_bar.update(cx, move |search_bar, cx| {
1685                        if search_bar.active_project_search.is_some() {
1686                            callback(search_bar, action, cx);
1687                            cx.notify();
1688                        } else {
1689                            cx.propagate();
1690                        }
1691                    });
1692                }
1693            });
1694        })
1695    });
1696}
1697
1698fn register_workspace_action_for_present_search<A: Action>(
1699    workspace: &mut Workspace,
1700    callback: fn(&mut Workspace, &A, &mut ViewContext<Workspace>),
1701) {
1702    workspace.register_action(move |workspace, action: &A, cx| {
1703        if workspace.has_active_modal(cx) {
1704            cx.propagate();
1705            return;
1706        }
1707
1708        let should_notify = workspace
1709            .active_pane()
1710            .read(cx)
1711            .toolbar()
1712            .read(cx)
1713            .item_of_type::<ProjectSearchBar>()
1714            .map(|search_bar| search_bar.read(cx).active_project_search.is_some())
1715            .unwrap_or(false);
1716        if should_notify {
1717            callback(workspace, action, cx);
1718            cx.notify();
1719        } else {
1720            cx.propagate();
1721        }
1722    });
1723}
1724
1725#[cfg(test)]
1726pub mod tests {
1727    use super::*;
1728    use editor::{display_map::DisplayRow, DisplayPoint};
1729    use gpui::{Action, TestAppContext, WindowHandle};
1730    use project::FakeFs;
1731    use serde_json::json;
1732    use settings::SettingsStore;
1733    use std::sync::Arc;
1734    use workspace::DeploySearch;
1735
1736    #[gpui::test]
1737    async fn test_project_search(cx: &mut TestAppContext) {
1738        init_test(cx);
1739
1740        let fs = FakeFs::new(cx.background_executor.clone());
1741        fs.insert_tree(
1742            "/dir",
1743            json!({
1744                "one.rs": "const ONE: usize = 1;",
1745                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
1746                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
1747                "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
1748            }),
1749        )
1750        .await;
1751        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
1752        let search = cx.new_model(|cx| ProjectSearch::new(project, cx));
1753        let search_view = cx.add_window(|cx| ProjectSearchView::new(search.clone(), cx, None));
1754
1755        perform_search(search_view, "TWO", cx);
1756        search_view.update(cx, |search_view, cx| {
1757            assert_eq!(
1758                search_view
1759                    .results_editor
1760                    .update(cx, |editor, cx| editor.display_text(cx)),
1761                "\n\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n"
1762            );
1763            let match_background_color = cx.theme().colors().search_match_background;
1764            assert_eq!(
1765                search_view
1766                    .results_editor
1767                    .update(cx, |editor, cx| editor.all_text_background_highlights(cx)),
1768                &[
1769                    (
1770                        DisplayPoint::new(DisplayRow(3), 32)..DisplayPoint::new(DisplayRow(3), 35),
1771                        match_background_color
1772                    ),
1773                    (
1774                        DisplayPoint::new(DisplayRow(3), 37)..DisplayPoint::new(DisplayRow(3), 40),
1775                        match_background_color
1776                    ),
1777                    (
1778                        DisplayPoint::new(DisplayRow(8), 6)..DisplayPoint::new(DisplayRow(8), 9),
1779                        match_background_color
1780                    )
1781                ]
1782            );
1783            assert_eq!(search_view.active_match_index, Some(0));
1784            assert_eq!(
1785                search_view
1786                    .results_editor
1787                    .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1788                [DisplayPoint::new(DisplayRow(3), 32)..DisplayPoint::new(DisplayRow(3), 35)]
1789            );
1790
1791            search_view.select_match(Direction::Next, cx);
1792        }).unwrap();
1793
1794        search_view
1795            .update(cx, |search_view, cx| {
1796                assert_eq!(search_view.active_match_index, Some(1));
1797                assert_eq!(
1798                    search_view
1799                        .results_editor
1800                        .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1801                    [DisplayPoint::new(DisplayRow(3), 37)..DisplayPoint::new(DisplayRow(3), 40)]
1802                );
1803                search_view.select_match(Direction::Next, cx);
1804            })
1805            .unwrap();
1806
1807        search_view
1808            .update(cx, |search_view, cx| {
1809                assert_eq!(search_view.active_match_index, Some(2));
1810                assert_eq!(
1811                    search_view
1812                        .results_editor
1813                        .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1814                    [DisplayPoint::new(DisplayRow(8), 6)..DisplayPoint::new(DisplayRow(8), 9)]
1815                );
1816                search_view.select_match(Direction::Next, cx);
1817            })
1818            .unwrap();
1819
1820        search_view
1821            .update(cx, |search_view, cx| {
1822                assert_eq!(search_view.active_match_index, Some(0));
1823                assert_eq!(
1824                    search_view
1825                        .results_editor
1826                        .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1827                    [DisplayPoint::new(DisplayRow(3), 32)..DisplayPoint::new(DisplayRow(3), 35)]
1828                );
1829                search_view.select_match(Direction::Prev, cx);
1830            })
1831            .unwrap();
1832
1833        search_view
1834            .update(cx, |search_view, cx| {
1835                assert_eq!(search_view.active_match_index, Some(2));
1836                assert_eq!(
1837                    search_view
1838                        .results_editor
1839                        .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1840                    [DisplayPoint::new(DisplayRow(8), 6)..DisplayPoint::new(DisplayRow(8), 9)]
1841                );
1842                search_view.select_match(Direction::Prev, cx);
1843            })
1844            .unwrap();
1845
1846        search_view
1847            .update(cx, |search_view, cx| {
1848                assert_eq!(search_view.active_match_index, Some(1));
1849                assert_eq!(
1850                    search_view
1851                        .results_editor
1852                        .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1853                    [DisplayPoint::new(DisplayRow(3), 37)..DisplayPoint::new(DisplayRow(3), 40)]
1854                );
1855            })
1856            .unwrap();
1857    }
1858
1859    #[gpui::test]
1860    async fn test_deploy_project_search_focus(cx: &mut TestAppContext) {
1861        init_test(cx);
1862
1863        let fs = FakeFs::new(cx.background_executor.clone());
1864        fs.insert_tree(
1865            "/dir",
1866            json!({
1867                "one.rs": "const ONE: usize = 1;",
1868                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
1869                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
1870                "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
1871            }),
1872        )
1873        .await;
1874        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
1875        let window = cx.add_window(|cx| Workspace::test_new(project, cx));
1876        let workspace = window;
1877        let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
1878
1879        let active_item = cx.read(|cx| {
1880            workspace
1881                .read(cx)
1882                .unwrap()
1883                .active_pane()
1884                .read(cx)
1885                .active_item()
1886                .and_then(|item| item.downcast::<ProjectSearchView>())
1887        });
1888        assert!(
1889            active_item.is_none(),
1890            "Expected no search panel to be active"
1891        );
1892
1893        window
1894            .update(cx, move |workspace, cx| {
1895                assert_eq!(workspace.panes().len(), 1);
1896                workspace.panes()[0].update(cx, move |pane, cx| {
1897                    pane.toolbar()
1898                        .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
1899                });
1900
1901                ProjectSearchView::deploy_search(workspace, &workspace::DeploySearch::find(), cx)
1902            })
1903            .unwrap();
1904
1905        let Some(search_view) = cx.read(|cx| {
1906            workspace
1907                .read(cx)
1908                .unwrap()
1909                .active_pane()
1910                .read(cx)
1911                .active_item()
1912                .and_then(|item| item.downcast::<ProjectSearchView>())
1913        }) else {
1914            panic!("Search view expected to appear after new search event trigger")
1915        };
1916
1917        cx.spawn(|mut cx| async move {
1918            window
1919                .update(&mut cx, |_, cx| {
1920                    cx.dispatch_action(ToggleFocus.boxed_clone())
1921                })
1922                .unwrap();
1923        })
1924        .detach();
1925        cx.background_executor.run_until_parked();
1926        window
1927            .update(cx, |_, cx| {
1928                search_view.update(cx, |search_view, cx| {
1929                assert!(
1930                    search_view.query_editor.focus_handle(cx).is_focused(cx),
1931                    "Empty search view should be focused after the toggle focus event: no results panel to focus on",
1932                );
1933           });
1934        }).unwrap();
1935
1936        window
1937            .update(cx, |_, cx| {
1938                search_view.update(cx, |search_view, cx| {
1939                    let query_editor = &search_view.query_editor;
1940                    assert!(
1941                        query_editor.focus_handle(cx).is_focused(cx),
1942                        "Search view should be focused after the new search view is activated",
1943                    );
1944                    let query_text = query_editor.read(cx).text(cx);
1945                    assert!(
1946                        query_text.is_empty(),
1947                        "New search query should be empty but got '{query_text}'",
1948                    );
1949                    let results_text = search_view
1950                        .results_editor
1951                        .update(cx, |editor, cx| editor.display_text(cx));
1952                    assert!(
1953                        results_text.is_empty(),
1954                        "Empty search view should have no results but got '{results_text}'"
1955                    );
1956                });
1957            })
1958            .unwrap();
1959
1960        window
1961            .update(cx, |_, cx| {
1962                search_view.update(cx, |search_view, cx| {
1963                    search_view.query_editor.update(cx, |query_editor, cx| {
1964                        query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", cx)
1965                    });
1966                    search_view.search(cx);
1967                });
1968            })
1969            .unwrap();
1970        cx.background_executor.run_until_parked();
1971        window
1972            .update(cx, |_, cx| {
1973            search_view.update(cx, |search_view, cx| {
1974                let results_text = search_view
1975                    .results_editor
1976                    .update(cx, |editor, cx| editor.display_text(cx));
1977                assert!(
1978                    results_text.is_empty(),
1979                    "Search view for mismatching query should have no results but got '{results_text}'"
1980                );
1981                assert!(
1982                    search_view.query_editor.focus_handle(cx).is_focused(cx),
1983                    "Search view should be focused after mismatching query had been used in search",
1984                );
1985            });
1986        }).unwrap();
1987
1988        cx.spawn(|mut cx| async move {
1989            window.update(&mut cx, |_, cx| {
1990                cx.dispatch_action(ToggleFocus.boxed_clone())
1991            })
1992        })
1993        .detach();
1994        cx.background_executor.run_until_parked();
1995        window.update(cx, |_, cx| {
1996            search_view.update(cx, |search_view, cx| {
1997                assert!(
1998                    search_view.query_editor.focus_handle(cx).is_focused(cx),
1999                    "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
2000                );
2001            });
2002        }).unwrap();
2003
2004        window
2005            .update(cx, |_, cx| {
2006                search_view.update(cx, |search_view, cx| {
2007                    search_view
2008                        .query_editor
2009                        .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
2010                    search_view.search(cx);
2011                });
2012            })
2013            .unwrap();
2014        cx.background_executor.run_until_parked();
2015        window.update(cx, |_, cx| {
2016            search_view.update(cx, |search_view, cx| {
2017                assert_eq!(
2018                    search_view
2019                        .results_editor
2020                        .update(cx, |editor, cx| editor.display_text(cx)),
2021                    "\n\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n",
2022                    "Search view results should match the query"
2023                );
2024                assert!(
2025                    search_view.results_editor.focus_handle(cx).is_focused(cx),
2026                    "Search view with mismatching query should be focused after search results are available",
2027                );
2028            });
2029        }).unwrap();
2030        cx.spawn(|mut cx| async move {
2031            window
2032                .update(&mut cx, |_, cx| {
2033                    cx.dispatch_action(ToggleFocus.boxed_clone())
2034                })
2035                .unwrap();
2036        })
2037        .detach();
2038        cx.background_executor.run_until_parked();
2039        window.update(cx, |_, cx| {
2040            search_view.update(cx, |search_view, cx| {
2041                assert!(
2042                    search_view.results_editor.focus_handle(cx).is_focused(cx),
2043                    "Search view with matching query should still have its results editor focused after the toggle focus event",
2044                );
2045            });
2046        }).unwrap();
2047
2048        workspace
2049            .update(cx, |workspace, cx| {
2050                ProjectSearchView::deploy_search(workspace, &workspace::DeploySearch::find(), cx)
2051            })
2052            .unwrap();
2053        window.update(cx, |_, cx| {
2054            search_view.update(cx, |search_view, cx| {
2055                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");
2056                assert_eq!(
2057                    search_view
2058                        .results_editor
2059                        .update(cx, |editor, cx| editor.display_text(cx)),
2060                    "\n\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n",
2061                    "Results should be unchanged after search view 2nd open in a row"
2062                );
2063                assert!(
2064                    search_view.query_editor.focus_handle(cx).is_focused(cx),
2065                    "Focus should be moved into query editor again after search view 2nd open in a row"
2066                );
2067            });
2068        }).unwrap();
2069
2070        cx.spawn(|mut cx| async move {
2071            window
2072                .update(&mut cx, |_, cx| {
2073                    cx.dispatch_action(ToggleFocus.boxed_clone())
2074                })
2075                .unwrap();
2076        })
2077        .detach();
2078        cx.background_executor.run_until_parked();
2079        window.update(cx, |_, cx| {
2080            search_view.update(cx, |search_view, cx| {
2081                assert!(
2082                    search_view.results_editor.focus_handle(cx).is_focused(cx),
2083                    "Search view with matching query should switch focus to the results editor after the toggle focus event",
2084                );
2085            });
2086        }).unwrap();
2087    }
2088
2089    #[gpui::test]
2090    async fn test_new_project_search_focus(cx: &mut TestAppContext) {
2091        init_test(cx);
2092
2093        let fs = FakeFs::new(cx.background_executor.clone());
2094        fs.insert_tree(
2095            "/dir",
2096            json!({
2097                "one.rs": "const ONE: usize = 1;",
2098                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2099                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2100                "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2101            }),
2102        )
2103        .await;
2104        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2105        let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2106        let workspace = window;
2107        let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
2108
2109        let active_item = cx.read(|cx| {
2110            workspace
2111                .read(cx)
2112                .unwrap()
2113                .active_pane()
2114                .read(cx)
2115                .active_item()
2116                .and_then(|item| item.downcast::<ProjectSearchView>())
2117        });
2118        assert!(
2119            active_item.is_none(),
2120            "Expected no search panel to be active"
2121        );
2122
2123        window
2124            .update(cx, move |workspace, cx| {
2125                assert_eq!(workspace.panes().len(), 1);
2126                workspace.panes()[0].update(cx, move |pane, cx| {
2127                    pane.toolbar()
2128                        .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2129                });
2130
2131                ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2132            })
2133            .unwrap();
2134
2135        let Some(search_view) = cx.read(|cx| {
2136            workspace
2137                .read(cx)
2138                .unwrap()
2139                .active_pane()
2140                .read(cx)
2141                .active_item()
2142                .and_then(|item| item.downcast::<ProjectSearchView>())
2143        }) else {
2144            panic!("Search view expected to appear after new search event trigger")
2145        };
2146
2147        cx.spawn(|mut cx| async move {
2148            window
2149                .update(&mut cx, |_, cx| {
2150                    cx.dispatch_action(ToggleFocus.boxed_clone())
2151                })
2152                .unwrap();
2153        })
2154        .detach();
2155        cx.background_executor.run_until_parked();
2156
2157        window.update(cx, |_, cx| {
2158            search_view.update(cx, |search_view, cx| {
2159                    assert!(
2160                        search_view.query_editor.focus_handle(cx).is_focused(cx),
2161                        "Empty search view should be focused after the toggle focus event: no results panel to focus on",
2162                    );
2163                });
2164        }).unwrap();
2165
2166        window
2167            .update(cx, |_, cx| {
2168                search_view.update(cx, |search_view, cx| {
2169                    let query_editor = &search_view.query_editor;
2170                    assert!(
2171                        query_editor.focus_handle(cx).is_focused(cx),
2172                        "Search view should be focused after the new search view is activated",
2173                    );
2174                    let query_text = query_editor.read(cx).text(cx);
2175                    assert!(
2176                        query_text.is_empty(),
2177                        "New search query should be empty but got '{query_text}'",
2178                    );
2179                    let results_text = search_view
2180                        .results_editor
2181                        .update(cx, |editor, cx| editor.display_text(cx));
2182                    assert!(
2183                        results_text.is_empty(),
2184                        "Empty search view should have no results but got '{results_text}'"
2185                    );
2186                });
2187            })
2188            .unwrap();
2189
2190        window
2191            .update(cx, |_, cx| {
2192                search_view.update(cx, |search_view, cx| {
2193                    search_view.query_editor.update(cx, |query_editor, cx| {
2194                        query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", cx)
2195                    });
2196                    search_view.search(cx);
2197                });
2198            })
2199            .unwrap();
2200
2201        cx.background_executor.run_until_parked();
2202        window
2203            .update(cx, |_, cx| {
2204                search_view.update(cx, |search_view, cx| {
2205                    let results_text = search_view
2206                        .results_editor
2207                        .update(cx, |editor, cx| editor.display_text(cx));
2208                    assert!(
2209                results_text.is_empty(),
2210                "Search view for mismatching query should have no results but got '{results_text}'"
2211            );
2212                    assert!(
2213                search_view.query_editor.focus_handle(cx).is_focused(cx),
2214                "Search view should be focused after mismatching query had been used in search",
2215            );
2216                });
2217            })
2218            .unwrap();
2219        cx.spawn(|mut cx| async move {
2220            window.update(&mut cx, |_, cx| {
2221                cx.dispatch_action(ToggleFocus.boxed_clone())
2222            })
2223        })
2224        .detach();
2225        cx.background_executor.run_until_parked();
2226        window.update(cx, |_, cx| {
2227            search_view.update(cx, |search_view, cx| {
2228                    assert!(
2229                        search_view.query_editor.focus_handle(cx).is_focused(cx),
2230                        "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
2231                    );
2232                });
2233        }).unwrap();
2234
2235        window
2236            .update(cx, |_, cx| {
2237                search_view.update(cx, |search_view, cx| {
2238                    search_view
2239                        .query_editor
2240                        .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
2241                    search_view.search(cx);
2242                })
2243            })
2244            .unwrap();
2245        cx.background_executor.run_until_parked();
2246        window.update(cx, |_, cx|
2247        search_view.update(cx, |search_view, cx| {
2248                assert_eq!(
2249                    search_view
2250                        .results_editor
2251                        .update(cx, |editor, cx| editor.display_text(cx)),
2252                    "\n\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n",
2253                    "Search view results should match the query"
2254                );
2255                assert!(
2256                    search_view.results_editor.focus_handle(cx).is_focused(cx),
2257                    "Search view with mismatching query should be focused after search results are available",
2258                );
2259            })).unwrap();
2260        cx.spawn(|mut cx| async move {
2261            window
2262                .update(&mut cx, |_, cx| {
2263                    cx.dispatch_action(ToggleFocus.boxed_clone())
2264                })
2265                .unwrap();
2266        })
2267        .detach();
2268        cx.background_executor.run_until_parked();
2269        window.update(cx, |_, cx| {
2270            search_view.update(cx, |search_view, cx| {
2271                    assert!(
2272                        search_view.results_editor.focus_handle(cx).is_focused(cx),
2273                        "Search view with matching query should still have its results editor focused after the toggle focus event",
2274                    );
2275                });
2276        }).unwrap();
2277
2278        workspace
2279            .update(cx, |workspace, cx| {
2280                ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2281            })
2282            .unwrap();
2283        cx.background_executor.run_until_parked();
2284        let Some(search_view_2) = cx.read(|cx| {
2285            workspace
2286                .read(cx)
2287                .unwrap()
2288                .active_pane()
2289                .read(cx)
2290                .active_item()
2291                .and_then(|item| item.downcast::<ProjectSearchView>())
2292        }) else {
2293            panic!("Search view expected to appear after new search event trigger")
2294        };
2295        assert!(
2296            search_view_2 != search_view,
2297            "New search view should be open after `workspace::NewSearch` event"
2298        );
2299
2300        window.update(cx, |_, cx| {
2301            search_view.update(cx, |search_view, cx| {
2302                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO", "First search view should not have an updated query");
2303                    assert_eq!(
2304                        search_view
2305                            .results_editor
2306                            .update(cx, |editor, cx| editor.display_text(cx)),
2307                        "\n\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n",
2308                        "Results of the first search view should not update too"
2309                    );
2310                    assert!(
2311                        !search_view.query_editor.focus_handle(cx).is_focused(cx),
2312                        "Focus should be moved away from the first search view"
2313                    );
2314                });
2315        }).unwrap();
2316
2317        window.update(cx, |_, cx| {
2318            search_view_2.update(cx, |search_view_2, cx| {
2319                    assert_eq!(
2320                        search_view_2.query_editor.read(cx).text(cx),
2321                        "two",
2322                        "New search view should get the query from the text cursor was at during the event spawn (first search view's first result)"
2323                    );
2324                    assert_eq!(
2325                        search_view_2
2326                            .results_editor
2327                            .update(cx, |editor, cx| editor.display_text(cx)),
2328                        "",
2329                        "No search results should be in the 2nd view yet, as we did not spawn a search for it"
2330                    );
2331                    assert!(
2332                        search_view_2.query_editor.focus_handle(cx).is_focused(cx),
2333                        "Focus should be moved into query editor of the new window"
2334                    );
2335                });
2336        }).unwrap();
2337
2338        window
2339            .update(cx, |_, cx| {
2340                search_view_2.update(cx, |search_view_2, cx| {
2341                    search_view_2
2342                        .query_editor
2343                        .update(cx, |query_editor, cx| query_editor.set_text("FOUR", cx));
2344                    search_view_2.search(cx);
2345                });
2346            })
2347            .unwrap();
2348
2349        cx.background_executor.run_until_parked();
2350        window.update(cx, |_, cx| {
2351            search_view_2.update(cx, |search_view_2, cx| {
2352                    assert_eq!(
2353                        search_view_2
2354                            .results_editor
2355                            .update(cx, |editor, cx| editor.display_text(cx)),
2356                        "\n\n\nconst FOUR: usize = one::ONE + three::THREE;\n",
2357                        "New search view with the updated query should have new search results"
2358                    );
2359                    assert!(
2360                        search_view_2.results_editor.focus_handle(cx).is_focused(cx),
2361                        "Search view with mismatching query should be focused after search results are available",
2362                    );
2363                });
2364        }).unwrap();
2365
2366        cx.spawn(|mut cx| async move {
2367            window
2368                .update(&mut cx, |_, cx| {
2369                    cx.dispatch_action(ToggleFocus.boxed_clone())
2370                })
2371                .unwrap();
2372        })
2373        .detach();
2374        cx.background_executor.run_until_parked();
2375        window.update(cx, |_, cx| {
2376            search_view_2.update(cx, |search_view_2, cx| {
2377                    assert!(
2378                        search_view_2.results_editor.focus_handle(cx).is_focused(cx),
2379                        "Search view with matching query should switch focus to the results editor after the toggle focus event",
2380                    );
2381                });}).unwrap();
2382    }
2383
2384    #[gpui::test]
2385    async fn test_new_project_search_in_directory(cx: &mut TestAppContext) {
2386        init_test(cx);
2387
2388        let fs = FakeFs::new(cx.background_executor.clone());
2389        fs.insert_tree(
2390            "/dir",
2391            json!({
2392                "a": {
2393                    "one.rs": "const ONE: usize = 1;",
2394                    "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2395                },
2396                "b": {
2397                    "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2398                    "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2399                },
2400            }),
2401        )
2402        .await;
2403        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2404        let worktree_id = project.read_with(cx, |project, cx| {
2405            project.worktrees(cx).next().unwrap().read(cx).id()
2406        });
2407        let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2408        let workspace = window.root(cx).unwrap();
2409        let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
2410
2411        let active_item = cx.read(|cx| {
2412            workspace
2413                .read(cx)
2414                .active_pane()
2415                .read(cx)
2416                .active_item()
2417                .and_then(|item| item.downcast::<ProjectSearchView>())
2418        });
2419        assert!(
2420            active_item.is_none(),
2421            "Expected no search panel to be active"
2422        );
2423
2424        window
2425            .update(cx, move |workspace, cx| {
2426                assert_eq!(workspace.panes().len(), 1);
2427                workspace.panes()[0].update(cx, move |pane, cx| {
2428                    pane.toolbar()
2429                        .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2430                });
2431            })
2432            .unwrap();
2433
2434        let a_dir_entry = cx.update(|cx| {
2435            workspace
2436                .read(cx)
2437                .project()
2438                .read(cx)
2439                .entry_for_path(&(worktree_id, "a").into(), cx)
2440                .expect("no entry for /a/ directory")
2441        });
2442        assert!(a_dir_entry.is_dir());
2443        window
2444            .update(cx, |workspace, cx| {
2445                ProjectSearchView::new_search_in_directory(workspace, &a_dir_entry.path, cx)
2446            })
2447            .unwrap();
2448
2449        let Some(search_view) = cx.read(|cx| {
2450            workspace
2451                .read(cx)
2452                .active_pane()
2453                .read(cx)
2454                .active_item()
2455                .and_then(|item| item.downcast::<ProjectSearchView>())
2456        }) else {
2457            panic!("Search view expected to appear after new search in directory event trigger")
2458        };
2459        cx.background_executor.run_until_parked();
2460        window
2461            .update(cx, |_, cx| {
2462                search_view.update(cx, |search_view, cx| {
2463                    assert!(
2464                        search_view.query_editor.focus_handle(cx).is_focused(cx),
2465                        "On new search in directory, focus should be moved into query editor"
2466                    );
2467                    search_view.excluded_files_editor.update(cx, |editor, cx| {
2468                        assert!(
2469                            editor.display_text(cx).is_empty(),
2470                            "New search in directory should not have any excluded files"
2471                        );
2472                    });
2473                    search_view.included_files_editor.update(cx, |editor, cx| {
2474                        assert_eq!(
2475                            editor.display_text(cx),
2476                            a_dir_entry.path.to_str().unwrap(),
2477                            "New search in directory should have included dir entry path"
2478                        );
2479                    });
2480                });
2481            })
2482            .unwrap();
2483        window
2484            .update(cx, |_, cx| {
2485                search_view.update(cx, |search_view, cx| {
2486                    search_view
2487                        .query_editor
2488                        .update(cx, |query_editor, cx| query_editor.set_text("const", cx));
2489                    search_view.search(cx);
2490                });
2491            })
2492            .unwrap();
2493        cx.background_executor.run_until_parked();
2494        window
2495            .update(cx, |_, cx| {
2496                search_view.update(cx, |search_view, cx| {
2497                    assert_eq!(
2498                search_view
2499                    .results_editor
2500                    .update(cx, |editor, cx| editor.display_text(cx)),
2501                "\n\n\nconst ONE: usize = 1;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n",
2502                "New search in directory should have a filter that matches a certain directory"
2503            );
2504                })
2505            })
2506            .unwrap();
2507    }
2508
2509    #[gpui::test]
2510    async fn test_search_query_history(cx: &mut TestAppContext) {
2511        init_test(cx);
2512
2513        let fs = FakeFs::new(cx.background_executor.clone());
2514        fs.insert_tree(
2515            "/dir",
2516            json!({
2517                "one.rs": "const ONE: usize = 1;",
2518                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2519                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2520                "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2521            }),
2522        )
2523        .await;
2524        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2525        let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2526        let workspace = window.root(cx).unwrap();
2527        let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
2528
2529        window
2530            .update(cx, {
2531                let search_bar = search_bar.clone();
2532                move |workspace, cx| {
2533                    assert_eq!(workspace.panes().len(), 1);
2534                    workspace.panes()[0].update(cx, move |pane, cx| {
2535                        pane.toolbar()
2536                            .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2537                    });
2538
2539                    ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2540                }
2541            })
2542            .unwrap();
2543
2544        let search_view = cx.read(|cx| {
2545            workspace
2546                .read(cx)
2547                .active_pane()
2548                .read(cx)
2549                .active_item()
2550                .and_then(|item| item.downcast::<ProjectSearchView>())
2551                .expect("Search view expected to appear after new search event trigger")
2552        });
2553
2554        // Add 3 search items into the history + another unsubmitted one.
2555        window
2556            .update(cx, |_, cx| {
2557                search_view.update(cx, |search_view, cx| {
2558                    search_view.search_options = SearchOptions::CASE_SENSITIVE;
2559                    search_view
2560                        .query_editor
2561                        .update(cx, |query_editor, cx| query_editor.set_text("ONE", cx));
2562                    search_view.search(cx);
2563                });
2564            })
2565            .unwrap();
2566
2567        cx.background_executor.run_until_parked();
2568        window
2569            .update(cx, |_, cx| {
2570                search_view.update(cx, |search_view, cx| {
2571                    search_view
2572                        .query_editor
2573                        .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
2574                    search_view.search(cx);
2575                });
2576            })
2577            .unwrap();
2578        cx.background_executor.run_until_parked();
2579        window
2580            .update(cx, |_, cx| {
2581                search_view.update(cx, |search_view, cx| {
2582                    search_view
2583                        .query_editor
2584                        .update(cx, |query_editor, cx| query_editor.set_text("THREE", cx));
2585                    search_view.search(cx);
2586                })
2587            })
2588            .unwrap();
2589        cx.background_executor.run_until_parked();
2590        window
2591            .update(cx, |_, cx| {
2592                search_view.update(cx, |search_view, cx| {
2593                    search_view.query_editor.update(cx, |query_editor, cx| {
2594                        query_editor.set_text("JUST_TEXT_INPUT", cx)
2595                    });
2596                })
2597            })
2598            .unwrap();
2599        cx.background_executor.run_until_parked();
2600
2601        // Ensure that the latest input with search settings is active.
2602        window
2603            .update(cx, |_, cx| {
2604                search_view.update(cx, |search_view, cx| {
2605                    assert_eq!(
2606                        search_view.query_editor.read(cx).text(cx),
2607                        "JUST_TEXT_INPUT"
2608                    );
2609                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2610                });
2611            })
2612            .unwrap();
2613
2614        // Next history query after the latest should set the query to the empty string.
2615        window
2616            .update(cx, |_, cx| {
2617                search_bar.update(cx, |search_bar, cx| {
2618                    search_bar.next_history_query(&NextHistoryQuery, cx);
2619                })
2620            })
2621            .unwrap();
2622        window
2623            .update(cx, |_, cx| {
2624                search_view.update(cx, |search_view, cx| {
2625                    assert_eq!(search_view.query_editor.read(cx).text(cx), "");
2626                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2627                });
2628            })
2629            .unwrap();
2630        window
2631            .update(cx, |_, cx| {
2632                search_bar.update(cx, |search_bar, cx| {
2633                    search_bar.next_history_query(&NextHistoryQuery, cx);
2634                })
2635            })
2636            .unwrap();
2637        window
2638            .update(cx, |_, cx| {
2639                search_view.update(cx, |search_view, cx| {
2640                    assert_eq!(search_view.query_editor.read(cx).text(cx), "");
2641                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2642                });
2643            })
2644            .unwrap();
2645
2646        // First previous query for empty current query should set the query to the latest submitted one.
2647        window
2648            .update(cx, |_, cx| {
2649                search_bar.update(cx, |search_bar, cx| {
2650                    search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2651                });
2652            })
2653            .unwrap();
2654        window
2655            .update(cx, |_, cx| {
2656                search_view.update(cx, |search_view, cx| {
2657                    assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
2658                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2659                });
2660            })
2661            .unwrap();
2662
2663        // Further previous items should go over the history in reverse order.
2664        window
2665            .update(cx, |_, cx| {
2666                search_bar.update(cx, |search_bar, cx| {
2667                    search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2668                });
2669            })
2670            .unwrap();
2671        window
2672            .update(cx, |_, cx| {
2673                search_view.update(cx, |search_view, cx| {
2674                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
2675                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2676                });
2677            })
2678            .unwrap();
2679
2680        // Previous items should never go behind the first history item.
2681        window
2682            .update(cx, |_, cx| {
2683                search_bar.update(cx, |search_bar, cx| {
2684                    search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2685                });
2686            })
2687            .unwrap();
2688        window
2689            .update(cx, |_, cx| {
2690                search_view.update(cx, |search_view, cx| {
2691                    assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
2692                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2693                });
2694            })
2695            .unwrap();
2696        window
2697            .update(cx, |_, cx| {
2698                search_bar.update(cx, |search_bar, cx| {
2699                    search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2700                });
2701            })
2702            .unwrap();
2703        window
2704            .update(cx, |_, cx| {
2705                search_view.update(cx, |search_view, cx| {
2706                    assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
2707                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2708                });
2709            })
2710            .unwrap();
2711
2712        // Next items should go over the history in the original order.
2713        window
2714            .update(cx, |_, cx| {
2715                search_bar.update(cx, |search_bar, cx| {
2716                    search_bar.next_history_query(&NextHistoryQuery, cx);
2717                });
2718            })
2719            .unwrap();
2720        window
2721            .update(cx, |_, cx| {
2722                search_view.update(cx, |search_view, cx| {
2723                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
2724                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2725                });
2726            })
2727            .unwrap();
2728
2729        window
2730            .update(cx, |_, cx| {
2731                search_view.update(cx, |search_view, cx| {
2732                    search_view
2733                        .query_editor
2734                        .update(cx, |query_editor, cx| query_editor.set_text("TWO_NEW", cx));
2735                    search_view.search(cx);
2736                });
2737            })
2738            .unwrap();
2739        cx.background_executor.run_until_parked();
2740        window
2741            .update(cx, |_, cx| {
2742                search_view.update(cx, |search_view, cx| {
2743                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
2744                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2745                });
2746            })
2747            .unwrap();
2748
2749        // New search input should add another entry to history and move the selection to the end of the history.
2750        window
2751            .update(cx, |_, cx| {
2752                search_bar.update(cx, |search_bar, cx| {
2753                    search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2754                });
2755            })
2756            .unwrap();
2757        window
2758            .update(cx, |_, cx| {
2759                search_view.update(cx, |search_view, cx| {
2760                    assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
2761                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2762                });
2763            })
2764            .unwrap();
2765        window
2766            .update(cx, |_, cx| {
2767                search_bar.update(cx, |search_bar, cx| {
2768                    search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2769                });
2770            })
2771            .unwrap();
2772        window
2773            .update(cx, |_, cx| {
2774                search_view.update(cx, |search_view, cx| {
2775                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
2776                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2777                });
2778            })
2779            .unwrap();
2780        window
2781            .update(cx, |_, cx| {
2782                search_bar.update(cx, |search_bar, cx| {
2783                    search_bar.next_history_query(&NextHistoryQuery, cx);
2784                });
2785            })
2786            .unwrap();
2787        window
2788            .update(cx, |_, cx| {
2789                search_view.update(cx, |search_view, cx| {
2790                    assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
2791                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2792                });
2793            })
2794            .unwrap();
2795        window
2796            .update(cx, |_, cx| {
2797                search_bar.update(cx, |search_bar, cx| {
2798                    search_bar.next_history_query(&NextHistoryQuery, cx);
2799                });
2800            })
2801            .unwrap();
2802        window
2803            .update(cx, |_, cx| {
2804                search_view.update(cx, |search_view, cx| {
2805                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
2806                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2807                });
2808            })
2809            .unwrap();
2810        window
2811            .update(cx, |_, cx| {
2812                search_bar.update(cx, |search_bar, cx| {
2813                    search_bar.next_history_query(&NextHistoryQuery, cx);
2814                });
2815            })
2816            .unwrap();
2817        window
2818            .update(cx, |_, cx| {
2819                search_view.update(cx, |search_view, cx| {
2820                    assert_eq!(search_view.query_editor.read(cx).text(cx), "");
2821                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2822                });
2823            })
2824            .unwrap();
2825    }
2826
2827    #[gpui::test]
2828    async fn test_search_query_history_with_multiple_views(cx: &mut TestAppContext) {
2829        init_test(cx);
2830
2831        let fs = FakeFs::new(cx.background_executor.clone());
2832        fs.insert_tree(
2833            "/dir",
2834            json!({
2835                "one.rs": "const ONE: usize = 1;",
2836            }),
2837        )
2838        .await;
2839        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2840        let worktree_id = project.update(cx, |this, cx| {
2841            this.worktrees(cx).next().unwrap().read(cx).id()
2842        });
2843
2844        let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2845        let workspace = window.root(cx).unwrap();
2846
2847        let panes: Vec<_> = window
2848            .update(cx, |this, _| this.panes().to_owned())
2849            .unwrap();
2850
2851        let search_bar_1 = window.build_view(cx, |_| ProjectSearchBar::new());
2852        let search_bar_2 = window.build_view(cx, |_| ProjectSearchBar::new());
2853
2854        assert_eq!(panes.len(), 1);
2855        let first_pane = panes.get(0).cloned().unwrap();
2856        assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 0);
2857        window
2858            .update(cx, |workspace, cx| {
2859                workspace.open_path(
2860                    (worktree_id, "one.rs"),
2861                    Some(first_pane.downgrade()),
2862                    true,
2863                    cx,
2864                )
2865            })
2866            .unwrap()
2867            .await
2868            .unwrap();
2869        assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
2870
2871        // Add a project search item to the first pane
2872        window
2873            .update(cx, {
2874                let search_bar = search_bar_1.clone();
2875                let pane = first_pane.clone();
2876                move |workspace, cx| {
2877                    pane.update(cx, move |pane, cx| {
2878                        pane.toolbar()
2879                            .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2880                    });
2881
2882                    ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2883                }
2884            })
2885            .unwrap();
2886        let search_view_1 = cx.read(|cx| {
2887            workspace
2888                .read(cx)
2889                .active_item(cx)
2890                .and_then(|item| item.downcast::<ProjectSearchView>())
2891                .expect("Search view expected to appear after new search event trigger")
2892        });
2893
2894        let second_pane = window
2895            .update(cx, |workspace, cx| {
2896                workspace.split_and_clone(first_pane.clone(), workspace::SplitDirection::Right, cx)
2897            })
2898            .unwrap()
2899            .unwrap();
2900        assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
2901
2902        assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
2903        assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 2);
2904
2905        // Add a project search item to the second pane
2906        window
2907            .update(cx, {
2908                let search_bar = search_bar_2.clone();
2909                let pane = second_pane.clone();
2910                move |workspace, cx| {
2911                    assert_eq!(workspace.panes().len(), 2);
2912                    pane.update(cx, move |pane, cx| {
2913                        pane.toolbar()
2914                            .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2915                    });
2916
2917                    ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2918                }
2919            })
2920            .unwrap();
2921
2922        let search_view_2 = cx.read(|cx| {
2923            workspace
2924                .read(cx)
2925                .active_item(cx)
2926                .and_then(|item| item.downcast::<ProjectSearchView>())
2927                .expect("Search view expected to appear after new search event trigger")
2928        });
2929
2930        cx.run_until_parked();
2931        assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 2);
2932        assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 2);
2933
2934        let update_search_view =
2935            |search_view: &View<ProjectSearchView>, query: &str, cx: &mut TestAppContext| {
2936                window
2937                    .update(cx, |_, cx| {
2938                        search_view.update(cx, |search_view, cx| {
2939                            search_view
2940                                .query_editor
2941                                .update(cx, |query_editor, cx| query_editor.set_text(query, cx));
2942                            search_view.search(cx);
2943                        });
2944                    })
2945                    .unwrap();
2946            };
2947
2948        let active_query =
2949            |search_view: &View<ProjectSearchView>, cx: &mut TestAppContext| -> String {
2950                window
2951                    .update(cx, |_, cx| {
2952                        search_view.update(cx, |search_view, cx| {
2953                            search_view.query_editor.read(cx).text(cx).to_string()
2954                        })
2955                    })
2956                    .unwrap()
2957            };
2958
2959        let select_prev_history_item =
2960            |search_bar: &View<ProjectSearchBar>, cx: &mut TestAppContext| {
2961                window
2962                    .update(cx, |_, cx| {
2963                        search_bar.update(cx, |search_bar, cx| {
2964                            search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2965                        })
2966                    })
2967                    .unwrap();
2968            };
2969
2970        let select_next_history_item =
2971            |search_bar: &View<ProjectSearchBar>, cx: &mut TestAppContext| {
2972                window
2973                    .update(cx, |_, cx| {
2974                        search_bar.update(cx, |search_bar, cx| {
2975                            search_bar.next_history_query(&NextHistoryQuery, cx);
2976                        })
2977                    })
2978                    .unwrap();
2979            };
2980
2981        update_search_view(&search_view_1, "ONE", cx);
2982        cx.background_executor.run_until_parked();
2983
2984        update_search_view(&search_view_2, "TWO", cx);
2985        cx.background_executor.run_until_parked();
2986
2987        assert_eq!(active_query(&search_view_1, cx), "ONE");
2988        assert_eq!(active_query(&search_view_2, cx), "TWO");
2989
2990        // Selecting previous history item should select the query from search view 1.
2991        select_prev_history_item(&search_bar_2, cx);
2992        assert_eq!(active_query(&search_view_2, cx), "ONE");
2993
2994        // Selecting the previous history item should not change the query as it is already the first item.
2995        select_prev_history_item(&search_bar_2, cx);
2996        assert_eq!(active_query(&search_view_2, cx), "ONE");
2997
2998        // Changing the query in search view 2 should not affect the history of search view 1.
2999        assert_eq!(active_query(&search_view_1, cx), "ONE");
3000
3001        // Deploying a new search in search view 2
3002        update_search_view(&search_view_2, "THREE", cx);
3003        cx.background_executor.run_until_parked();
3004
3005        select_next_history_item(&search_bar_2, cx);
3006        assert_eq!(active_query(&search_view_2, cx), "");
3007
3008        select_prev_history_item(&search_bar_2, cx);
3009        assert_eq!(active_query(&search_view_2, cx), "THREE");
3010
3011        select_prev_history_item(&search_bar_2, cx);
3012        assert_eq!(active_query(&search_view_2, cx), "TWO");
3013
3014        select_prev_history_item(&search_bar_2, cx);
3015        assert_eq!(active_query(&search_view_2, cx), "ONE");
3016
3017        select_prev_history_item(&search_bar_2, cx);
3018        assert_eq!(active_query(&search_view_2, cx), "ONE");
3019
3020        // Search view 1 should now see the query from search view 2.
3021        assert_eq!(active_query(&search_view_1, cx), "ONE");
3022
3023        select_next_history_item(&search_bar_2, cx);
3024        assert_eq!(active_query(&search_view_2, cx), "TWO");
3025
3026        // Here is the new query from search view 2
3027        select_next_history_item(&search_bar_2, cx);
3028        assert_eq!(active_query(&search_view_2, cx), "THREE");
3029
3030        select_next_history_item(&search_bar_2, cx);
3031        assert_eq!(active_query(&search_view_2, cx), "");
3032
3033        select_next_history_item(&search_bar_1, cx);
3034        assert_eq!(active_query(&search_view_1, cx), "TWO");
3035
3036        select_next_history_item(&search_bar_1, cx);
3037        assert_eq!(active_query(&search_view_1, cx), "THREE");
3038
3039        select_next_history_item(&search_bar_1, cx);
3040        assert_eq!(active_query(&search_view_1, cx), "");
3041    }
3042
3043    #[gpui::test]
3044    async fn test_deploy_search_with_multiple_panes(cx: &mut TestAppContext) {
3045        init_test(cx);
3046
3047        // Setup 2 panes, both with a file open and one with a project search.
3048        let fs = FakeFs::new(cx.background_executor.clone());
3049        fs.insert_tree(
3050            "/dir",
3051            json!({
3052                "one.rs": "const ONE: usize = 1;",
3053            }),
3054        )
3055        .await;
3056        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
3057        let worktree_id = project.update(cx, |this, cx| {
3058            this.worktrees(cx).next().unwrap().read(cx).id()
3059        });
3060        let window = cx.add_window(|cx| Workspace::test_new(project, cx));
3061        let panes: Vec<_> = window
3062            .update(cx, |this, _| this.panes().to_owned())
3063            .unwrap();
3064        assert_eq!(panes.len(), 1);
3065        let first_pane = panes.get(0).cloned().unwrap();
3066        assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 0);
3067        window
3068            .update(cx, |workspace, cx| {
3069                workspace.open_path(
3070                    (worktree_id, "one.rs"),
3071                    Some(first_pane.downgrade()),
3072                    true,
3073                    cx,
3074                )
3075            })
3076            .unwrap()
3077            .await
3078            .unwrap();
3079        assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3080        let second_pane = window
3081            .update(cx, |workspace, cx| {
3082                workspace.split_and_clone(first_pane.clone(), workspace::SplitDirection::Right, cx)
3083            })
3084            .unwrap()
3085            .unwrap();
3086        assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
3087        assert!(window
3088            .update(cx, |_, cx| second_pane
3089                .focus_handle(cx)
3090                .contains_focused(cx))
3091            .unwrap());
3092        let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
3093        window
3094            .update(cx, {
3095                let search_bar = search_bar.clone();
3096                let pane = first_pane.clone();
3097                move |workspace, cx| {
3098                    assert_eq!(workspace.panes().len(), 2);
3099                    pane.update(cx, move |pane, cx| {
3100                        pane.toolbar()
3101                            .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
3102                    });
3103                }
3104            })
3105            .unwrap();
3106
3107        // Add a project search item to the second pane
3108        window
3109            .update(cx, {
3110                let search_bar = search_bar.clone();
3111                let pane = second_pane.clone();
3112                move |workspace, cx| {
3113                    assert_eq!(workspace.panes().len(), 2);
3114                    pane.update(cx, move |pane, cx| {
3115                        pane.toolbar()
3116                            .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
3117                    });
3118
3119                    ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
3120                }
3121            })
3122            .unwrap();
3123
3124        cx.run_until_parked();
3125        assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 2);
3126        assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3127
3128        // Focus the first pane
3129        window
3130            .update(cx, |workspace, cx| {
3131                assert_eq!(workspace.active_pane(), &second_pane);
3132                second_pane.update(cx, |this, cx| {
3133                    assert_eq!(this.active_item_index(), 1);
3134                    this.activate_prev_item(false, cx);
3135                    assert_eq!(this.active_item_index(), 0);
3136                });
3137                workspace.activate_pane_in_direction(workspace::SplitDirection::Left, cx);
3138            })
3139            .unwrap();
3140        window
3141            .update(cx, |workspace, cx| {
3142                assert_eq!(workspace.active_pane(), &first_pane);
3143                assert_eq!(first_pane.read(cx).items_len(), 1);
3144                assert_eq!(second_pane.read(cx).items_len(), 2);
3145            })
3146            .unwrap();
3147
3148        // Deploy a new search
3149        cx.dispatch_action(window.into(), DeploySearch::find());
3150
3151        // Both panes should now have a project search in them
3152        window
3153            .update(cx, |workspace, cx| {
3154                assert_eq!(workspace.active_pane(), &first_pane);
3155                first_pane.update(cx, |this, _| {
3156                    assert_eq!(this.active_item_index(), 1);
3157                    assert_eq!(this.items_len(), 2);
3158                });
3159                second_pane.update(cx, |this, cx| {
3160                    assert!(!cx.focus_handle().contains_focused(cx));
3161                    assert_eq!(this.items_len(), 2);
3162                });
3163            })
3164            .unwrap();
3165
3166        // Focus the second pane's non-search item
3167        window
3168            .update(cx, |_workspace, cx| {
3169                second_pane.update(cx, |pane, cx| pane.activate_next_item(true, cx));
3170            })
3171            .unwrap();
3172
3173        // Deploy a new search
3174        cx.dispatch_action(window.into(), DeploySearch::find());
3175
3176        // The project search view should now be focused in the second pane
3177        // And the number of items should be unchanged.
3178        window
3179            .update(cx, |_workspace, cx| {
3180                second_pane.update(cx, |pane, _cx| {
3181                    assert!(pane
3182                        .active_item()
3183                        .unwrap()
3184                        .downcast::<ProjectSearchView>()
3185                        .is_some());
3186
3187                    assert_eq!(pane.items_len(), 2);
3188                });
3189            })
3190            .unwrap();
3191    }
3192
3193    #[gpui::test]
3194    async fn test_scroll_search_results_to_top(cx: &mut TestAppContext) {
3195        init_test(cx);
3196
3197        // We need many lines in the search results to be able to scroll the window
3198        let fs = FakeFs::new(cx.background_executor.clone());
3199        fs.insert_tree(
3200            "/dir",
3201            json!({
3202                "1.txt": "\n\n\n\n\n A \n\n\n\n\n",
3203                "2.txt": "\n\n\n\n\n A \n\n\n\n\n",
3204                "3.rs": "\n\n\n\n\n A \n\n\n\n\n",
3205                "4.rs": "\n\n\n\n\n A \n\n\n\n\n",
3206                "5.rs": "\n\n\n\n\n A \n\n\n\n\n",
3207                "6.rs": "\n\n\n\n\n A \n\n\n\n\n",
3208                "7.rs": "\n\n\n\n\n A \n\n\n\n\n",
3209                "8.rs": "\n\n\n\n\n A \n\n\n\n\n",
3210                "9.rs": "\n\n\n\n\n A \n\n\n\n\n",
3211                "a.rs": "\n\n\n\n\n A \n\n\n\n\n",
3212                "b.rs": "\n\n\n\n\n B \n\n\n\n\n",
3213                "c.rs": "\n\n\n\n\n B \n\n\n\n\n",
3214                "d.rs": "\n\n\n\n\n B \n\n\n\n\n",
3215                "e.rs": "\n\n\n\n\n B \n\n\n\n\n",
3216                "f.rs": "\n\n\n\n\n B \n\n\n\n\n",
3217                "g.rs": "\n\n\n\n\n B \n\n\n\n\n",
3218                "h.rs": "\n\n\n\n\n B \n\n\n\n\n",
3219                "i.rs": "\n\n\n\n\n B \n\n\n\n\n",
3220                "j.rs": "\n\n\n\n\n B \n\n\n\n\n",
3221                "k.rs": "\n\n\n\n\n B \n\n\n\n\n",
3222            }),
3223        )
3224        .await;
3225        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
3226        let search = cx.new_model(|cx| ProjectSearch::new(project, cx));
3227        let search_view = cx.add_window(|cx| ProjectSearchView::new(search.clone(), cx, None));
3228
3229        // First search
3230        perform_search(search_view, "A", cx);
3231        search_view
3232            .update(cx, |search_view, cx| {
3233                search_view.results_editor.update(cx, |results_editor, cx| {
3234                    // Results are correct and scrolled to the top
3235                    assert_eq!(
3236                        results_editor.display_text(cx).match_indices(" A ").count(),
3237                        10
3238                    );
3239                    assert_eq!(results_editor.scroll_position(cx), Point::default());
3240
3241                    // Scroll results all the way down
3242                    results_editor.scroll(Point::new(0., f32::MAX), Some(Axis::Vertical), cx);
3243                });
3244            })
3245            .expect("unable to update search view");
3246
3247        // Second search
3248        perform_search(search_view, "B", cx);
3249        search_view
3250            .update(cx, |search_view, cx| {
3251                search_view.results_editor.update(cx, |results_editor, cx| {
3252                    // Results are correct...
3253                    assert_eq!(
3254                        results_editor.display_text(cx).match_indices(" B ").count(),
3255                        10
3256                    );
3257                    // ...and scrolled back to the top
3258                    assert_eq!(results_editor.scroll_position(cx), Point::default());
3259                });
3260            })
3261            .expect("unable to update search view");
3262    }
3263
3264    fn init_test(cx: &mut TestAppContext) {
3265        cx.update(|cx| {
3266            let settings = SettingsStore::test(cx);
3267            cx.set_global(settings);
3268
3269            theme::init(theme::LoadThemes::JustBase, cx);
3270
3271            language::init(cx);
3272            client::init_settings(cx);
3273            editor::init(cx);
3274            workspace::init_settings(cx);
3275            Project::init_settings(cx);
3276            super::init(cx);
3277        });
3278    }
3279
3280    fn perform_search(
3281        search_view: WindowHandle<ProjectSearchView>,
3282        text: impl Into<Arc<str>>,
3283        cx: &mut TestAppContext,
3284    ) {
3285        search_view
3286            .update(cx, |search_view, cx| {
3287                search_view
3288                    .query_editor
3289                    .update(cx, |query_editor, cx| query_editor.set_text(text, cx));
3290                search_view.search(cx);
3291            })
3292            .unwrap();
3293        cx.background_executor.run_until_parked();
3294    }
3295}