project_search.rs

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