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};
  23use settings::Settings;
  24use smol::stream::StreamExt;
  25use std::{
  26    any::{Any, TypeId},
  27    mem,
  28    ops::{Not, Range},
  29    path::{Path, PathBuf},
  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        _: PathBuf,
 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()), 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                    cx,
 793                );
 794            }
 795        }
 796    }
 797
 798    // Add another search tab to the workspace.
 799    fn new_search(
 800        workspace: &mut Workspace,
 801        _: &workspace::NewSearch,
 802        cx: &mut ViewContext<Workspace>,
 803    ) {
 804        Self::existing_or_new_search(workspace, None, &DeploySearch::find(), cx)
 805    }
 806
 807    fn existing_or_new_search(
 808        workspace: &mut Workspace,
 809        existing: Option<View<ProjectSearchView>>,
 810        action: &workspace::DeploySearch,
 811        cx: &mut ViewContext<Workspace>,
 812    ) {
 813        let query = workspace.active_item(cx).and_then(|item| {
 814            let editor = item.act_as::<Editor>(cx)?;
 815            let query = editor.query_suggestion(cx);
 816            if query.is_empty() {
 817                None
 818            } else {
 819                Some(query)
 820            }
 821        });
 822
 823        let search = if let Some(existing) = existing {
 824            workspace.activate_item(&existing, cx);
 825            existing
 826        } else {
 827            let settings = cx
 828                .global::<ActiveSettings>()
 829                .0
 830                .get(&workspace.project().downgrade());
 831
 832            let settings = if let Some(settings) = settings {
 833                Some(settings.clone())
 834            } else {
 835                None
 836            };
 837
 838            let model = cx.new_model(|cx| ProjectSearch::new(workspace.project().clone(), cx));
 839            let view = cx.new_view(|cx| ProjectSearchView::new(model, cx, settings));
 840
 841            workspace.add_item_to_active_pane(Box::new(view.clone()), cx);
 842            view
 843        };
 844
 845        search.update(cx, |search, cx| {
 846            search.replace_enabled = action.replace_enabled;
 847            if let Some(query) = query {
 848                search.set_query(&query, cx);
 849            }
 850            search.focus_query_editor(cx)
 851        });
 852    }
 853
 854    fn search(&mut self, cx: &mut ViewContext<Self>) {
 855        if let Some(query) = self.build_search_query(cx) {
 856            self.model.update(cx, |model, cx| model.search(query, cx));
 857        }
 858    }
 859
 860    fn build_search_query(&mut self, cx: &mut ViewContext<Self>) -> Option<SearchQuery> {
 861        // Do not bail early in this function, as we want to fill out `self.panels_with_errors`.
 862        let text = self.query_editor.read(cx).text(cx);
 863        let included_files =
 864            match Self::parse_path_matches(&self.included_files_editor.read(cx).text(cx)) {
 865                Ok(included_files) => {
 866                    let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Include);
 867                    if should_unmark_error {
 868                        cx.notify();
 869                    }
 870                    included_files
 871                }
 872                Err(_e) => {
 873                    let should_mark_error = self.panels_with_errors.insert(InputPanel::Include);
 874                    if should_mark_error {
 875                        cx.notify();
 876                    }
 877                    vec![]
 878                }
 879            };
 880        let excluded_files =
 881            match Self::parse_path_matches(&self.excluded_files_editor.read(cx).text(cx)) {
 882                Ok(excluded_files) => {
 883                    let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Exclude);
 884                    if should_unmark_error {
 885                        cx.notify();
 886                    }
 887
 888                    excluded_files
 889                }
 890                Err(_e) => {
 891                    let should_mark_error = self.panels_with_errors.insert(InputPanel::Exclude);
 892                    if should_mark_error {
 893                        cx.notify();
 894                    }
 895                    vec![]
 896                }
 897            };
 898
 899        let query = if self.search_options.contains(SearchOptions::REGEX) {
 900            match SearchQuery::regex(
 901                text,
 902                self.search_options.contains(SearchOptions::WHOLE_WORD),
 903                self.search_options.contains(SearchOptions::CASE_SENSITIVE),
 904                self.search_options.contains(SearchOptions::INCLUDE_IGNORED),
 905                included_files,
 906                excluded_files,
 907            ) {
 908                Ok(query) => {
 909                    let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Query);
 910                    if should_unmark_error {
 911                        cx.notify();
 912                    }
 913
 914                    Some(query)
 915                }
 916                Err(_e) => {
 917                    let should_mark_error = self.panels_with_errors.insert(InputPanel::Query);
 918                    if should_mark_error {
 919                        cx.notify();
 920                    }
 921
 922                    None
 923                }
 924            }
 925        } else {
 926            match SearchQuery::text(
 927                text,
 928                self.search_options.contains(SearchOptions::WHOLE_WORD),
 929                self.search_options.contains(SearchOptions::CASE_SENSITIVE),
 930                self.search_options.contains(SearchOptions::INCLUDE_IGNORED),
 931                included_files,
 932                excluded_files,
 933            ) {
 934                Ok(query) => {
 935                    let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Query);
 936                    if should_unmark_error {
 937                        cx.notify();
 938                    }
 939
 940                    Some(query)
 941                }
 942                Err(_e) => {
 943                    let should_mark_error = self.panels_with_errors.insert(InputPanel::Query);
 944                    if should_mark_error {
 945                        cx.notify();
 946                    }
 947
 948                    None
 949                }
 950            }
 951        };
 952        if !self.panels_with_errors.is_empty() {
 953            return None;
 954        }
 955        if query.as_ref().is_some_and(|query| query.is_empty()) {
 956            return None;
 957        }
 958        query
 959    }
 960
 961    fn parse_path_matches(text: &str) -> anyhow::Result<Vec<PathMatcher>> {
 962        text.split(',')
 963            .map(str::trim)
 964            .filter(|maybe_glob_str| !maybe_glob_str.is_empty())
 965            .map(|maybe_glob_str| {
 966                PathMatcher::new(maybe_glob_str)
 967                    .with_context(|| format!("parsing {maybe_glob_str} as path matcher"))
 968            })
 969            .collect()
 970    }
 971
 972    fn select_match(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
 973        if let Some(index) = self.active_match_index {
 974            let match_ranges = self.model.read(cx).match_ranges.clone();
 975            let new_index = self.results_editor.update(cx, |editor, cx| {
 976                editor.match_index_for_direction(&match_ranges, index, direction, 1, cx)
 977            });
 978
 979            let range_to_select = match_ranges[new_index].clone();
 980            self.results_editor.update(cx, |editor, cx| {
 981                let range_to_select = editor.range_for_match(&range_to_select);
 982                editor.unfold_ranges([range_to_select.clone()], false, true, cx);
 983                editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
 984                    s.select_ranges([range_to_select])
 985                });
 986            });
 987        }
 988    }
 989
 990    fn focus_query_editor(&mut self, cx: &mut ViewContext<Self>) {
 991        self.query_editor.update(cx, |query_editor, cx| {
 992            query_editor.select_all(&SelectAll, cx);
 993        });
 994        self.query_editor_was_focused = true;
 995        let editor_handle = self.query_editor.focus_handle(cx);
 996        cx.focus(&editor_handle);
 997    }
 998
 999    fn set_query(&mut self, query: &str, cx: &mut ViewContext<Self>) {
1000        self.query_editor
1001            .update(cx, |query_editor, cx| query_editor.set_text(query, cx));
1002    }
1003
1004    fn focus_results_editor(&mut self, cx: &mut ViewContext<Self>) {
1005        self.query_editor.update(cx, |query_editor, cx| {
1006            let cursor = query_editor.selections.newest_anchor().head();
1007            query_editor.change_selections(None, cx, |s| s.select_ranges([cursor..cursor]));
1008        });
1009        self.query_editor_was_focused = false;
1010        let results_handle = self.results_editor.focus_handle(cx);
1011        cx.focus(&results_handle);
1012    }
1013
1014    fn model_changed(&mut self, cx: &mut ViewContext<Self>) {
1015        let match_ranges = self.model.read(cx).match_ranges.clone();
1016        if match_ranges.is_empty() {
1017            self.active_match_index = None;
1018        } else {
1019            self.active_match_index = Some(0);
1020            self.update_match_index(cx);
1021            let prev_search_id = mem::replace(&mut self.search_id, self.model.read(cx).search_id);
1022            let is_new_search = self.search_id != prev_search_id;
1023            self.results_editor.update(cx, |editor, cx| {
1024                if is_new_search {
1025                    let range_to_select = match_ranges
1026                        .first()
1027                        .map(|range| editor.range_for_match(range));
1028                    editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
1029                        s.select_ranges(range_to_select)
1030                    });
1031                    editor.scroll(Point::default(), Some(Axis::Vertical), cx);
1032                }
1033                editor.highlight_background::<Self>(
1034                    &match_ranges,
1035                    |theme| theme.search_match_background,
1036                    cx,
1037                );
1038            });
1039            if is_new_search && self.query_editor.focus_handle(cx).is_focused(cx) {
1040                self.focus_results_editor(cx);
1041            }
1042        }
1043
1044        cx.emit(ViewEvent::UpdateTab);
1045        cx.notify();
1046    }
1047
1048    fn update_match_index(&mut self, cx: &mut ViewContext<Self>) {
1049        let results_editor = self.results_editor.read(cx);
1050        let new_index = active_match_index(
1051            &self.model.read(cx).match_ranges,
1052            &results_editor.selections.newest_anchor().head(),
1053            &results_editor.buffer().read(cx).snapshot(cx),
1054        );
1055        if self.active_match_index != new_index {
1056            self.active_match_index = new_index;
1057            cx.notify();
1058        }
1059    }
1060
1061    pub fn has_matches(&self) -> bool {
1062        self.active_match_index.is_some()
1063    }
1064
1065    fn landing_text_minor(&self) -> SharedString {
1066        "Include/exclude specific paths with the filter option. Matching exact word and/or casing is available too.".into()
1067    }
1068
1069    fn border_color_for(&self, panel: InputPanel, cx: &WindowContext) -> Hsla {
1070        if self.panels_with_errors.contains(&panel) {
1071            Color::Error.color(cx)
1072        } else {
1073            cx.theme().colors().border
1074        }
1075    }
1076
1077    fn move_focus_to_results(&mut self, cx: &mut ViewContext<Self>) {
1078        if !self.results_editor.focus_handle(cx).is_focused(cx)
1079            && !self.model.read(cx).match_ranges.is_empty()
1080        {
1081            cx.stop_propagation();
1082            return self.focus_results_editor(cx);
1083        }
1084    }
1085}
1086
1087impl ProjectSearchBar {
1088    pub fn new() -> Self {
1089        Self {
1090            active_project_search: None,
1091            subscription: None,
1092        }
1093    }
1094
1095    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
1096        if let Some(search_view) = self.active_project_search.as_ref() {
1097            search_view.update(cx, |search_view, cx| {
1098                if !search_view
1099                    .replacement_editor
1100                    .focus_handle(cx)
1101                    .is_focused(cx)
1102                {
1103                    cx.stop_propagation();
1104                    search_view.search(cx);
1105                }
1106            });
1107        }
1108    }
1109
1110    fn tab(&mut self, _: &editor::actions::Tab, cx: &mut ViewContext<Self>) {
1111        self.cycle_field(Direction::Next, cx);
1112    }
1113
1114    fn tab_previous(&mut self, _: &editor::actions::TabPrev, cx: &mut ViewContext<Self>) {
1115        self.cycle_field(Direction::Prev, cx);
1116    }
1117
1118    fn focus_search(&mut self, cx: &mut ViewContext<Self>) {
1119        if let Some(search_view) = self.active_project_search.as_ref() {
1120            search_view.update(cx, |search_view, cx| {
1121                search_view.query_editor.focus_handle(cx).focus(cx);
1122            });
1123        }
1124    }
1125
1126    fn cycle_field(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
1127        let active_project_search = match &self.active_project_search {
1128            Some(active_project_search) => active_project_search,
1129
1130            None => {
1131                return;
1132            }
1133        };
1134
1135        active_project_search.update(cx, |project_view, cx| {
1136            let mut views = vec![&project_view.query_editor];
1137            if project_view.replace_enabled {
1138                views.push(&project_view.replacement_editor);
1139            }
1140            if project_view.filters_enabled {
1141                views.extend([
1142                    &project_view.included_files_editor,
1143                    &project_view.excluded_files_editor,
1144                ]);
1145            }
1146            let current_index = match views
1147                .iter()
1148                .enumerate()
1149                .find(|(_, view)| view.focus_handle(cx).is_focused(cx))
1150            {
1151                Some((index, _)) => index,
1152                None => return,
1153            };
1154
1155            let new_index = match direction {
1156                Direction::Next => (current_index + 1) % views.len(),
1157                Direction::Prev if current_index == 0 => views.len() - 1,
1158                Direction::Prev => (current_index - 1) % views.len(),
1159            };
1160            let next_focus_handle = views[new_index].focus_handle(cx);
1161            cx.focus(&next_focus_handle);
1162            cx.stop_propagation();
1163        });
1164    }
1165
1166    fn toggle_search_option(&mut self, option: SearchOptions, cx: &mut ViewContext<Self>) -> bool {
1167        if let Some(search_view) = self.active_project_search.as_ref() {
1168            search_view.update(cx, |search_view, cx| {
1169                search_view.toggle_search_option(option, cx);
1170                search_view.search(cx);
1171            });
1172
1173            cx.notify();
1174            true
1175        } else {
1176            false
1177        }
1178    }
1179
1180    fn toggle_replace(&mut self, _: &ToggleReplace, cx: &mut ViewContext<Self>) {
1181        if let Some(search) = &self.active_project_search {
1182            search.update(cx, |this, cx| {
1183                this.replace_enabled = !this.replace_enabled;
1184                let editor_to_focus = if this.replace_enabled {
1185                    this.replacement_editor.focus_handle(cx)
1186                } else {
1187                    this.query_editor.focus_handle(cx)
1188                };
1189                cx.focus(&editor_to_focus);
1190                cx.notify();
1191            });
1192        }
1193    }
1194
1195    fn toggle_filters(&mut self, cx: &mut ViewContext<Self>) -> bool {
1196        if let Some(search_view) = self.active_project_search.as_ref() {
1197            search_view.update(cx, |search_view, cx| {
1198                search_view.toggle_filters(cx);
1199                search_view
1200                    .included_files_editor
1201                    .update(cx, |_, cx| cx.notify());
1202                search_view
1203                    .excluded_files_editor
1204                    .update(cx, |_, cx| cx.notify());
1205                cx.refresh();
1206                cx.notify();
1207            });
1208            cx.notify();
1209            true
1210        } else {
1211            false
1212        }
1213    }
1214
1215    fn move_focus_to_results(&self, cx: &mut ViewContext<Self>) {
1216        if let Some(search_view) = self.active_project_search.as_ref() {
1217            search_view.update(cx, |search_view, cx| {
1218                search_view.move_focus_to_results(cx);
1219            });
1220            cx.notify();
1221        }
1222    }
1223
1224    fn is_option_enabled(&self, option: SearchOptions, cx: &AppContext) -> bool {
1225        if let Some(search) = self.active_project_search.as_ref() {
1226            search.read(cx).search_options.contains(option)
1227        } else {
1228            false
1229        }
1230    }
1231
1232    fn next_history_query(&mut self, _: &NextHistoryQuery, cx: &mut ViewContext<Self>) {
1233        if let Some(search_view) = self.active_project_search.as_ref() {
1234            search_view.update(cx, |search_view, cx| {
1235                let new_query = search_view.model.update(cx, |model, cx| {
1236                    if let Some(new_query) = model.project.update(cx, |project, _| {
1237                        project
1238                            .search_history_mut()
1239                            .next(&mut model.search_history_cursor)
1240                            .map(str::to_string)
1241                    }) {
1242                        new_query
1243                    } else {
1244                        model.search_history_cursor.reset();
1245                        String::new()
1246                    }
1247                });
1248                search_view.set_query(&new_query, cx);
1249            });
1250        }
1251    }
1252
1253    fn previous_history_query(&mut self, _: &PreviousHistoryQuery, cx: &mut ViewContext<Self>) {
1254        if let Some(search_view) = self.active_project_search.as_ref() {
1255            search_view.update(cx, |search_view, cx| {
1256                if search_view.query_editor.read(cx).text(cx).is_empty() {
1257                    if let Some(new_query) = search_view
1258                        .model
1259                        .read(cx)
1260                        .project
1261                        .read(cx)
1262                        .search_history()
1263                        .current(&search_view.model.read(cx).search_history_cursor)
1264                        .map(str::to_string)
1265                    {
1266                        search_view.set_query(&new_query, cx);
1267                        return;
1268                    }
1269                }
1270
1271                if let Some(new_query) = search_view.model.update(cx, |model, cx| {
1272                    model.project.update(cx, |project, _| {
1273                        project
1274                            .search_history_mut()
1275                            .previous(&mut model.search_history_cursor)
1276                            .map(str::to_string)
1277                    })
1278                }) {
1279                    search_view.set_query(&new_query, cx);
1280                }
1281            });
1282        }
1283    }
1284
1285    fn select_next_match(&mut self, _: &SelectNextMatch, cx: &mut ViewContext<Self>) {
1286        if let Some(search) = self.active_project_search.as_ref() {
1287            search.update(cx, |this, cx| {
1288                this.select_match(Direction::Next, cx);
1289            })
1290        }
1291    }
1292
1293    fn select_prev_match(&mut self, _: &SelectPrevMatch, cx: &mut ViewContext<Self>) {
1294        if let Some(search) = self.active_project_search.as_ref() {
1295            search.update(cx, |this, cx| {
1296                this.select_match(Direction::Prev, cx);
1297            })
1298        }
1299    }
1300
1301    fn render_text_input(&self, editor: &View<Editor>, cx: &ViewContext<Self>) -> impl IntoElement {
1302        let settings = ThemeSettings::get_global(cx);
1303        let text_style = TextStyle {
1304            color: if editor.read(cx).read_only(cx) {
1305                cx.theme().colors().text_disabled
1306            } else {
1307                cx.theme().colors().text
1308            },
1309            font_family: settings.buffer_font.family.clone(),
1310            font_features: settings.buffer_font.features,
1311            font_size: rems(0.875).into(),
1312            font_weight: FontWeight::NORMAL,
1313            font_style: FontStyle::Normal,
1314            line_height: relative(1.3),
1315            background_color: None,
1316            underline: None,
1317            strikethrough: None,
1318            white_space: WhiteSpace::Normal,
1319        };
1320
1321        EditorElement::new(
1322            &editor,
1323            EditorStyle {
1324                background: cx.theme().colors().editor_background,
1325                local_player: cx.theme().players().local(),
1326                text: text_style,
1327                ..Default::default()
1328            },
1329        )
1330    }
1331}
1332
1333impl Render for ProjectSearchBar {
1334    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1335        let Some(search) = self.active_project_search.clone() else {
1336            return div();
1337        };
1338        let search = search.read(cx);
1339
1340        let query_column = h_flex()
1341            .flex_1()
1342            .h_8()
1343            .mr_2()
1344            .px_2()
1345            .py_1()
1346            .border_1()
1347            .border_color(search.border_color_for(InputPanel::Query, cx))
1348            .rounded_lg()
1349            .min_w(rems(MIN_INPUT_WIDTH_REMS))
1350            .max_w(rems(MAX_INPUT_WIDTH_REMS))
1351            .on_action(cx.listener(|this, action, cx| this.confirm(action, cx)))
1352            .on_action(cx.listener(|this, action, cx| this.previous_history_query(action, cx)))
1353            .on_action(cx.listener(|this, action, cx| this.next_history_query(action, cx)))
1354            .child(self.render_text_input(&search.query_editor, cx))
1355            .child(
1356                h_flex()
1357                    .child(SearchOptions::CASE_SENSITIVE.as_button(
1358                        self.is_option_enabled(SearchOptions::CASE_SENSITIVE, cx),
1359                        cx.listener(|this, _, cx| {
1360                            this.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
1361                        }),
1362                    ))
1363                    .child(SearchOptions::WHOLE_WORD.as_button(
1364                        self.is_option_enabled(SearchOptions::WHOLE_WORD, cx),
1365                        cx.listener(|this, _, cx| {
1366                            this.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
1367                        }),
1368                    ))
1369                    .child(SearchOptions::REGEX.as_button(
1370                        self.is_option_enabled(SearchOptions::REGEX, cx),
1371                        cx.listener(|this, _, cx| {
1372                            this.toggle_search_option(SearchOptions::REGEX, cx);
1373                        }),
1374                    )),
1375            );
1376
1377        let mode_column = v_flex().items_start().justify_start().child(
1378            h_flex()
1379                .child(
1380                    IconButton::new("project-search-filter-button", IconName::Filter)
1381                        .tooltip(|cx| Tooltip::for_action("Toggle filters", &ToggleFilters, cx))
1382                        .on_click(cx.listener(|this, _, cx| {
1383                            this.toggle_filters(cx);
1384                        }))
1385                        .selected(
1386                            self.active_project_search
1387                                .as_ref()
1388                                .map(|search| search.read(cx).filters_enabled)
1389                                .unwrap_or_default(),
1390                        ),
1391                )
1392                .child(
1393                    IconButton::new("project-search-toggle-replace", IconName::Replace)
1394                        .on_click(cx.listener(|this, _, cx| {
1395                            this.toggle_replace(&ToggleReplace, cx);
1396                        }))
1397                        .selected(
1398                            self.active_project_search
1399                                .as_ref()
1400                                .map(|search| search.read(cx).replace_enabled)
1401                                .unwrap_or_default(),
1402                        )
1403                        .tooltip(|cx| Tooltip::for_action("Toggle replace", &ToggleReplace, cx)),
1404                ),
1405        );
1406
1407        let match_text = search
1408            .active_match_index
1409            .and_then(|index| {
1410                let index = index + 1;
1411                let match_quantity = search.model.read(cx).match_ranges.len();
1412                if match_quantity > 0 {
1413                    debug_assert!(match_quantity >= index);
1414                    Some(format!("{index}/{match_quantity}").to_string())
1415                } else {
1416                    None
1417                }
1418            })
1419            .unwrap_or_else(|| "0/0".to_string());
1420
1421        let limit_reached = search.model.read(cx).limit_reached;
1422
1423        let matches_column = h_flex()
1424            .child(
1425                IconButton::new("project-search-prev-match", IconName::ChevronLeft)
1426                    .disabled(search.active_match_index.is_none())
1427                    .on_click(cx.listener(|this, _, cx| {
1428                        if let Some(search) = this.active_project_search.as_ref() {
1429                            search.update(cx, |this, cx| {
1430                                this.select_match(Direction::Prev, cx);
1431                            })
1432                        }
1433                    }))
1434                    .tooltip(|cx| {
1435                        Tooltip::for_action("Go to previous match", &SelectPrevMatch, cx)
1436                    }),
1437            )
1438            .child(
1439                IconButton::new("project-search-next-match", IconName::ChevronRight)
1440                    .disabled(search.active_match_index.is_none())
1441                    .on_click(cx.listener(|this, _, cx| {
1442                        if let Some(search) = this.active_project_search.as_ref() {
1443                            search.update(cx, |this, cx| {
1444                                this.select_match(Direction::Next, cx);
1445                            })
1446                        }
1447                    }))
1448                    .tooltip(|cx| Tooltip::for_action("Go to next match", &SelectNextMatch, cx)),
1449            )
1450            .child(
1451                h_flex()
1452                    .min_w(rems_from_px(40.))
1453                    .child(
1454                        Label::new(match_text).color(if search.active_match_index.is_some() {
1455                            Color::Default
1456                        } else {
1457                            Color::Disabled
1458                        }),
1459                    ),
1460            )
1461            .when(limit_reached, |this| {
1462                this.child(
1463                    div()
1464                        .child(Label::new("Search limit reached").color(Color::Warning))
1465                        .ml_2(),
1466                )
1467            });
1468
1469        let search_line = h_flex()
1470            .flex_1()
1471            .child(query_column)
1472            .child(mode_column)
1473            .child(matches_column);
1474
1475        let replace_line = search.replace_enabled.then(|| {
1476            let replace_column = h_flex()
1477                .flex_1()
1478                .min_w(rems(MIN_INPUT_WIDTH_REMS))
1479                .max_w(rems(MAX_INPUT_WIDTH_REMS))
1480                .h_8()
1481                .px_2()
1482                .py_1()
1483                .border_1()
1484                .border_color(cx.theme().colors().border)
1485                .rounded_lg()
1486                .child(self.render_text_input(&search.replacement_editor, cx));
1487            let replace_actions = h_flex().when(search.replace_enabled, |this| {
1488                this.child(
1489                    IconButton::new("project-search-replace-next", IconName::ReplaceNext)
1490                        .on_click(cx.listener(|this, _, cx| {
1491                            if let Some(search) = this.active_project_search.as_ref() {
1492                                search.update(cx, |this, cx| {
1493                                    this.replace_next(&ReplaceNext, cx);
1494                                })
1495                            }
1496                        }))
1497                        .tooltip(|cx| Tooltip::for_action("Replace next match", &ReplaceNext, cx)),
1498                )
1499                .child(
1500                    IconButton::new("project-search-replace-all", IconName::ReplaceAll)
1501                        .on_click(cx.listener(|this, _, cx| {
1502                            if let Some(search) = this.active_project_search.as_ref() {
1503                                search.update(cx, |this, cx| {
1504                                    this.replace_all(&ReplaceAll, cx);
1505                                })
1506                            }
1507                        }))
1508                        .tooltip(|cx| Tooltip::for_action("Replace all matches", &ReplaceAll, cx)),
1509                )
1510            });
1511            h_flex()
1512                .gap_2()
1513                .child(replace_column)
1514                .child(replace_actions)
1515        });
1516
1517        let filter_line = search.filters_enabled.then(|| {
1518            h_flex()
1519                .w_full()
1520                .gap_2()
1521                .child(
1522                    h_flex()
1523                        .flex_1()
1524                        .min_w(rems(MIN_INPUT_WIDTH_REMS))
1525                        .max_w(rems(MAX_INPUT_WIDTH_REMS))
1526                        .h_8()
1527                        .px_2()
1528                        .py_1()
1529                        .border_1()
1530                        .border_color(search.border_color_for(InputPanel::Include, cx))
1531                        .rounded_lg()
1532                        .child(self.render_text_input(&search.included_files_editor, cx))
1533                        .child(
1534                            SearchOptions::INCLUDE_IGNORED.as_button(
1535                                search
1536                                    .search_options
1537                                    .contains(SearchOptions::INCLUDE_IGNORED),
1538                                cx.listener(|this, _, cx| {
1539                                    this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, cx);
1540                                }),
1541                            ),
1542                        ),
1543                )
1544                .child(
1545                    h_flex()
1546                        .flex_1()
1547                        .min_w(rems(MIN_INPUT_WIDTH_REMS))
1548                        .max_w(rems(MAX_INPUT_WIDTH_REMS))
1549                        .h_8()
1550                        .px_2()
1551                        .py_1()
1552                        .border_1()
1553                        .border_color(search.border_color_for(InputPanel::Exclude, cx))
1554                        .rounded_lg()
1555                        .child(self.render_text_input(&search.excluded_files_editor, cx)),
1556                )
1557        });
1558
1559        v_flex()
1560            .key_context("ProjectSearchBar")
1561            .on_action(cx.listener(|this, _: &ToggleFocus, cx| this.move_focus_to_results(cx)))
1562            .on_action(cx.listener(|this, _: &ToggleFilters, cx| {
1563                this.toggle_filters(cx);
1564            }))
1565            .capture_action(cx.listener(|this, action, cx| {
1566                this.tab(action, cx);
1567                cx.stop_propagation();
1568            }))
1569            .capture_action(cx.listener(|this, action, cx| {
1570                this.tab_previous(action, cx);
1571                cx.stop_propagation();
1572            }))
1573            .on_action(cx.listener(|this, action, cx| this.confirm(action, cx)))
1574            .on_action(cx.listener(|this, action, cx| {
1575                this.toggle_replace(action, cx);
1576            }))
1577            .on_action(cx.listener(|this, _: &ToggleWholeWord, cx| {
1578                this.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
1579            }))
1580            .on_action(cx.listener(|this, _: &ToggleCaseSensitive, cx| {
1581                this.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
1582            }))
1583            .on_action(cx.listener(|this, action, cx| {
1584                if let Some(search) = this.active_project_search.as_ref() {
1585                    search.update(cx, |this, cx| {
1586                        this.replace_next(action, cx);
1587                    })
1588                }
1589            }))
1590            .on_action(cx.listener(|this, action, cx| {
1591                if let Some(search) = this.active_project_search.as_ref() {
1592                    search.update(cx, |this, cx| {
1593                        this.replace_all(action, cx);
1594                    })
1595                }
1596            }))
1597            .when(search.filters_enabled, |this| {
1598                this.on_action(cx.listener(|this, _: &ToggleIncludeIgnored, cx| {
1599                    this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, cx);
1600                }))
1601            })
1602            .on_action(cx.listener(Self::select_next_match))
1603            .on_action(cx.listener(Self::select_prev_match))
1604            .gap_2()
1605            .w_full()
1606            .child(search_line)
1607            .children(replace_line)
1608            .children(filter_line)
1609    }
1610}
1611
1612impl EventEmitter<ToolbarItemEvent> for ProjectSearchBar {}
1613
1614impl ToolbarItemView for ProjectSearchBar {
1615    fn set_active_pane_item(
1616        &mut self,
1617        active_pane_item: Option<&dyn ItemHandle>,
1618        cx: &mut ViewContext<Self>,
1619    ) -> ToolbarItemLocation {
1620        cx.notify();
1621        self.subscription = None;
1622        self.active_project_search = None;
1623        if let Some(search) = active_pane_item.and_then(|i| i.downcast::<ProjectSearchView>()) {
1624            self.subscription = Some(cx.observe(&search, |_, _, cx| cx.notify()));
1625            self.active_project_search = Some(search);
1626            ToolbarItemLocation::PrimaryLeft {}
1627        } else {
1628            ToolbarItemLocation::Hidden
1629        }
1630    }
1631
1632    fn row_count(&self, cx: &WindowContext<'_>) -> usize {
1633        if let Some(search) = self.active_project_search.as_ref() {
1634            if search.read(cx).filters_enabled {
1635                return 2;
1636            }
1637        }
1638        1
1639    }
1640}
1641
1642fn register_workspace_action<A: Action>(
1643    workspace: &mut Workspace,
1644    callback: fn(&mut ProjectSearchBar, &A, &mut ViewContext<ProjectSearchBar>),
1645) {
1646    workspace.register_action(move |workspace, action: &A, cx| {
1647        if workspace.has_active_modal(cx) {
1648            cx.propagate();
1649            return;
1650        }
1651
1652        workspace.active_pane().update(cx, |pane, cx| {
1653            pane.toolbar().update(cx, move |workspace, cx| {
1654                if let Some(search_bar) = workspace.item_of_type::<ProjectSearchBar>() {
1655                    search_bar.update(cx, move |search_bar, cx| {
1656                        if search_bar.active_project_search.is_some() {
1657                            callback(search_bar, action, cx);
1658                            cx.notify();
1659                        } else {
1660                            cx.propagate();
1661                        }
1662                    });
1663                }
1664            });
1665        })
1666    });
1667}
1668
1669fn register_workspace_action_for_present_search<A: Action>(
1670    workspace: &mut Workspace,
1671    callback: fn(&mut Workspace, &A, &mut ViewContext<Workspace>),
1672) {
1673    workspace.register_action(move |workspace, action: &A, cx| {
1674        if workspace.has_active_modal(cx) {
1675            cx.propagate();
1676            return;
1677        }
1678
1679        let should_notify = workspace
1680            .active_pane()
1681            .read(cx)
1682            .toolbar()
1683            .read(cx)
1684            .item_of_type::<ProjectSearchBar>()
1685            .map(|search_bar| search_bar.read(cx).active_project_search.is_some())
1686            .unwrap_or(false);
1687        if should_notify {
1688            callback(workspace, action, cx);
1689            cx.notify();
1690        } else {
1691            cx.propagate();
1692        }
1693    });
1694}
1695
1696#[cfg(test)]
1697pub mod tests {
1698    use super::*;
1699    use editor::DisplayPoint;
1700    use gpui::{Action, TestAppContext, WindowHandle};
1701    use project::FakeFs;
1702    use serde_json::json;
1703    use settings::SettingsStore;
1704    use std::sync::Arc;
1705    use workspace::DeploySearch;
1706
1707    #[gpui::test]
1708    async fn test_project_search(cx: &mut TestAppContext) {
1709        init_test(cx);
1710
1711        let fs = FakeFs::new(cx.background_executor.clone());
1712        fs.insert_tree(
1713            "/dir",
1714            json!({
1715                "one.rs": "const ONE: usize = 1;",
1716                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
1717                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
1718                "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
1719            }),
1720        )
1721        .await;
1722        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
1723        let search = cx.new_model(|cx| ProjectSearch::new(project, cx));
1724        let search_view = cx.add_window(|cx| ProjectSearchView::new(search.clone(), cx, None));
1725
1726        perform_search(search_view, "TWO", cx);
1727        search_view.update(cx, |search_view, cx| {
1728            assert_eq!(
1729                search_view
1730                    .results_editor
1731                    .update(cx, |editor, cx| editor.display_text(cx)),
1732                "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;"
1733            );
1734            let match_background_color = cx.theme().colors().search_match_background;
1735            assert_eq!(
1736                search_view
1737                    .results_editor
1738                    .update(cx, |editor, cx| editor.all_text_background_highlights(cx)),
1739                &[
1740                    (
1741                        DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35),
1742                        match_background_color
1743                    ),
1744                    (
1745                        DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40),
1746                        match_background_color
1747                    ),
1748                    (
1749                        DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9),
1750                        match_background_color
1751                    )
1752                ]
1753            );
1754            assert_eq!(search_view.active_match_index, Some(0));
1755            assert_eq!(
1756                search_view
1757                    .results_editor
1758                    .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1759                [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
1760            );
1761
1762            search_view.select_match(Direction::Next, cx);
1763        }).unwrap();
1764
1765        search_view
1766            .update(cx, |search_view, cx| {
1767                assert_eq!(search_view.active_match_index, Some(1));
1768                assert_eq!(
1769                    search_view
1770                        .results_editor
1771                        .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1772                    [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
1773                );
1774                search_view.select_match(Direction::Next, cx);
1775            })
1776            .unwrap();
1777
1778        search_view
1779            .update(cx, |search_view, cx| {
1780                assert_eq!(search_view.active_match_index, Some(2));
1781                assert_eq!(
1782                    search_view
1783                        .results_editor
1784                        .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1785                    [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
1786                );
1787                search_view.select_match(Direction::Next, cx);
1788            })
1789            .unwrap();
1790
1791        search_view
1792            .update(cx, |search_view, cx| {
1793                assert_eq!(search_view.active_match_index, Some(0));
1794                assert_eq!(
1795                    search_view
1796                        .results_editor
1797                        .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1798                    [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
1799                );
1800                search_view.select_match(Direction::Prev, cx);
1801            })
1802            .unwrap();
1803
1804        search_view
1805            .update(cx, |search_view, cx| {
1806                assert_eq!(search_view.active_match_index, Some(2));
1807                assert_eq!(
1808                    search_view
1809                        .results_editor
1810                        .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1811                    [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
1812                );
1813                search_view.select_match(Direction::Prev, cx);
1814            })
1815            .unwrap();
1816
1817        search_view
1818            .update(cx, |search_view, cx| {
1819                assert_eq!(search_view.active_match_index, Some(1));
1820                assert_eq!(
1821                    search_view
1822                        .results_editor
1823                        .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1824                    [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
1825                );
1826            })
1827            .unwrap();
1828    }
1829
1830    #[gpui::test]
1831    async fn test_deploy_project_search_focus(cx: &mut TestAppContext) {
1832        init_test(cx);
1833
1834        let fs = FakeFs::new(cx.background_executor.clone());
1835        fs.insert_tree(
1836            "/dir",
1837            json!({
1838                "one.rs": "const ONE: usize = 1;",
1839                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
1840                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
1841                "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
1842            }),
1843        )
1844        .await;
1845        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
1846        let window = cx.add_window(|cx| Workspace::test_new(project, cx));
1847        let workspace = window;
1848        let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
1849
1850        let active_item = cx.read(|cx| {
1851            workspace
1852                .read(cx)
1853                .unwrap()
1854                .active_pane()
1855                .read(cx)
1856                .active_item()
1857                .and_then(|item| item.downcast::<ProjectSearchView>())
1858        });
1859        assert!(
1860            active_item.is_none(),
1861            "Expected no search panel to be active"
1862        );
1863
1864        window
1865            .update(cx, move |workspace, cx| {
1866                assert_eq!(workspace.panes().len(), 1);
1867                workspace.panes()[0].update(cx, move |pane, cx| {
1868                    pane.toolbar()
1869                        .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
1870                });
1871
1872                ProjectSearchView::deploy_search(workspace, &workspace::DeploySearch::find(), cx)
1873            })
1874            .unwrap();
1875
1876        let Some(search_view) = cx.read(|cx| {
1877            workspace
1878                .read(cx)
1879                .unwrap()
1880                .active_pane()
1881                .read(cx)
1882                .active_item()
1883                .and_then(|item| item.downcast::<ProjectSearchView>())
1884        }) else {
1885            panic!("Search view expected to appear after new search event trigger")
1886        };
1887
1888        cx.spawn(|mut cx| async move {
1889            window
1890                .update(&mut cx, |_, cx| {
1891                    cx.dispatch_action(ToggleFocus.boxed_clone())
1892                })
1893                .unwrap();
1894        })
1895        .detach();
1896        cx.background_executor.run_until_parked();
1897        window
1898            .update(cx, |_, cx| {
1899                search_view.update(cx, |search_view, cx| {
1900                assert!(
1901                    search_view.query_editor.focus_handle(cx).is_focused(cx),
1902                    "Empty search view should be focused after the toggle focus event: no results panel to focus on",
1903                );
1904           });
1905        }).unwrap();
1906
1907        window
1908            .update(cx, |_, cx| {
1909                search_view.update(cx, |search_view, cx| {
1910                    let query_editor = &search_view.query_editor;
1911                    assert!(
1912                        query_editor.focus_handle(cx).is_focused(cx),
1913                        "Search view should be focused after the new search view is activated",
1914                    );
1915                    let query_text = query_editor.read(cx).text(cx);
1916                    assert!(
1917                        query_text.is_empty(),
1918                        "New search query should be empty but got '{query_text}'",
1919                    );
1920                    let results_text = search_view
1921                        .results_editor
1922                        .update(cx, |editor, cx| editor.display_text(cx));
1923                    assert!(
1924                        results_text.is_empty(),
1925                        "Empty search view should have no results but got '{results_text}'"
1926                    );
1927                });
1928            })
1929            .unwrap();
1930
1931        window
1932            .update(cx, |_, cx| {
1933                search_view.update(cx, |search_view, cx| {
1934                    search_view.query_editor.update(cx, |query_editor, cx| {
1935                        query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", cx)
1936                    });
1937                    search_view.search(cx);
1938                });
1939            })
1940            .unwrap();
1941        cx.background_executor.run_until_parked();
1942        window
1943            .update(cx, |_, cx| {
1944            search_view.update(cx, |search_view, cx| {
1945                let results_text = search_view
1946                    .results_editor
1947                    .update(cx, |editor, cx| editor.display_text(cx));
1948                assert!(
1949                    results_text.is_empty(),
1950                    "Search view for mismatching query should have no results but got '{results_text}'"
1951                );
1952                assert!(
1953                    search_view.query_editor.focus_handle(cx).is_focused(cx),
1954                    "Search view should be focused after mismatching query had been used in search",
1955                );
1956            });
1957        }).unwrap();
1958
1959        cx.spawn(|mut cx| async move {
1960            window.update(&mut cx, |_, cx| {
1961                cx.dispatch_action(ToggleFocus.boxed_clone())
1962            })
1963        })
1964        .detach();
1965        cx.background_executor.run_until_parked();
1966        window.update(cx, |_, cx| {
1967            search_view.update(cx, |search_view, cx| {
1968                assert!(
1969                    search_view.query_editor.focus_handle(cx).is_focused(cx),
1970                    "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
1971                );
1972            });
1973        }).unwrap();
1974
1975        window
1976            .update(cx, |_, cx| {
1977                search_view.update(cx, |search_view, cx| {
1978                    search_view
1979                        .query_editor
1980                        .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
1981                    search_view.search(cx);
1982                });
1983            })
1984            .unwrap();
1985        cx.background_executor.run_until_parked();
1986        window.update(cx, |_, cx| {
1987            search_view.update(cx, |search_view, cx| {
1988                assert_eq!(
1989                    search_view
1990                        .results_editor
1991                        .update(cx, |editor, cx| editor.display_text(cx)),
1992                    "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
1993                    "Search view results should match the query"
1994                );
1995                assert!(
1996                    search_view.results_editor.focus_handle(cx).is_focused(cx),
1997                    "Search view with mismatching query should be focused after search results are available",
1998                );
1999            });
2000        }).unwrap();
2001        cx.spawn(|mut cx| async move {
2002            window
2003                .update(&mut cx, |_, cx| {
2004                    cx.dispatch_action(ToggleFocus.boxed_clone())
2005                })
2006                .unwrap();
2007        })
2008        .detach();
2009        cx.background_executor.run_until_parked();
2010        window.update(cx, |_, cx| {
2011            search_view.update(cx, |search_view, cx| {
2012                assert!(
2013                    search_view.results_editor.focus_handle(cx).is_focused(cx),
2014                    "Search view with matching query should still have its results editor focused after the toggle focus event",
2015                );
2016            });
2017        }).unwrap();
2018
2019        workspace
2020            .update(cx, |workspace, cx| {
2021                ProjectSearchView::deploy_search(workspace, &workspace::DeploySearch::find(), cx)
2022            })
2023            .unwrap();
2024        window.update(cx, |_, cx| {
2025            search_view.update(cx, |search_view, cx| {
2026                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");
2027                assert_eq!(
2028                    search_view
2029                        .results_editor
2030                        .update(cx, |editor, cx| editor.display_text(cx)),
2031                    "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2032                    "Results should be unchanged after search view 2nd open in a row"
2033                );
2034                assert!(
2035                    search_view.query_editor.focus_handle(cx).is_focused(cx),
2036                    "Focus should be moved into query editor again after search view 2nd open in a row"
2037                );
2038            });
2039        }).unwrap();
2040
2041        cx.spawn(|mut cx| async move {
2042            window
2043                .update(&mut cx, |_, cx| {
2044                    cx.dispatch_action(ToggleFocus.boxed_clone())
2045                })
2046                .unwrap();
2047        })
2048        .detach();
2049        cx.background_executor.run_until_parked();
2050        window.update(cx, |_, cx| {
2051            search_view.update(cx, |search_view, cx| {
2052                assert!(
2053                    search_view.results_editor.focus_handle(cx).is_focused(cx),
2054                    "Search view with matching query should switch focus to the results editor after the toggle focus event",
2055                );
2056            });
2057        }).unwrap();
2058    }
2059
2060    #[gpui::test]
2061    async fn test_new_project_search_focus(cx: &mut TestAppContext) {
2062        init_test(cx);
2063
2064        let fs = FakeFs::new(cx.background_executor.clone());
2065        fs.insert_tree(
2066            "/dir",
2067            json!({
2068                "one.rs": "const ONE: usize = 1;",
2069                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2070                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2071                "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2072            }),
2073        )
2074        .await;
2075        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2076        let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2077        let workspace = window;
2078        let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
2079
2080        let active_item = cx.read(|cx| {
2081            workspace
2082                .read(cx)
2083                .unwrap()
2084                .active_pane()
2085                .read(cx)
2086                .active_item()
2087                .and_then(|item| item.downcast::<ProjectSearchView>())
2088        });
2089        assert!(
2090            active_item.is_none(),
2091            "Expected no search panel to be active"
2092        );
2093
2094        window
2095            .update(cx, move |workspace, cx| {
2096                assert_eq!(workspace.panes().len(), 1);
2097                workspace.panes()[0].update(cx, move |pane, cx| {
2098                    pane.toolbar()
2099                        .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2100                });
2101
2102                ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2103            })
2104            .unwrap();
2105
2106        let Some(search_view) = cx.read(|cx| {
2107            workspace
2108                .read(cx)
2109                .unwrap()
2110                .active_pane()
2111                .read(cx)
2112                .active_item()
2113                .and_then(|item| item.downcast::<ProjectSearchView>())
2114        }) else {
2115            panic!("Search view expected to appear after new search event trigger")
2116        };
2117
2118        cx.spawn(|mut cx| async move {
2119            window
2120                .update(&mut cx, |_, cx| {
2121                    cx.dispatch_action(ToggleFocus.boxed_clone())
2122                })
2123                .unwrap();
2124        })
2125        .detach();
2126        cx.background_executor.run_until_parked();
2127
2128        window.update(cx, |_, cx| {
2129            search_view.update(cx, |search_view, cx| {
2130                    assert!(
2131                        search_view.query_editor.focus_handle(cx).is_focused(cx),
2132                        "Empty search view should be focused after the toggle focus event: no results panel to focus on",
2133                    );
2134                });
2135        }).unwrap();
2136
2137        window
2138            .update(cx, |_, cx| {
2139                search_view.update(cx, |search_view, cx| {
2140                    let query_editor = &search_view.query_editor;
2141                    assert!(
2142                        query_editor.focus_handle(cx).is_focused(cx),
2143                        "Search view should be focused after the new search view is activated",
2144                    );
2145                    let query_text = query_editor.read(cx).text(cx);
2146                    assert!(
2147                        query_text.is_empty(),
2148                        "New search query should be empty but got '{query_text}'",
2149                    );
2150                    let results_text = search_view
2151                        .results_editor
2152                        .update(cx, |editor, cx| editor.display_text(cx));
2153                    assert!(
2154                        results_text.is_empty(),
2155                        "Empty search view should have no results but got '{results_text}'"
2156                    );
2157                });
2158            })
2159            .unwrap();
2160
2161        window
2162            .update(cx, |_, cx| {
2163                search_view.update(cx, |search_view, cx| {
2164                    search_view.query_editor.update(cx, |query_editor, cx| {
2165                        query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", cx)
2166                    });
2167                    search_view.search(cx);
2168                });
2169            })
2170            .unwrap();
2171
2172        cx.background_executor.run_until_parked();
2173        window
2174            .update(cx, |_, cx| {
2175                search_view.update(cx, |search_view, cx| {
2176                    let results_text = search_view
2177                        .results_editor
2178                        .update(cx, |editor, cx| editor.display_text(cx));
2179                    assert!(
2180                results_text.is_empty(),
2181                "Search view for mismatching query should have no results but got '{results_text}'"
2182            );
2183                    assert!(
2184                search_view.query_editor.focus_handle(cx).is_focused(cx),
2185                "Search view should be focused after mismatching query had been used in search",
2186            );
2187                });
2188            })
2189            .unwrap();
2190        cx.spawn(|mut cx| async move {
2191            window.update(&mut cx, |_, cx| {
2192                cx.dispatch_action(ToggleFocus.boxed_clone())
2193            })
2194        })
2195        .detach();
2196        cx.background_executor.run_until_parked();
2197        window.update(cx, |_, cx| {
2198            search_view.update(cx, |search_view, cx| {
2199                    assert!(
2200                        search_view.query_editor.focus_handle(cx).is_focused(cx),
2201                        "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
2202                    );
2203                });
2204        }).unwrap();
2205
2206        window
2207            .update(cx, |_, cx| {
2208                search_view.update(cx, |search_view, cx| {
2209                    search_view
2210                        .query_editor
2211                        .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
2212                    search_view.search(cx);
2213                })
2214            })
2215            .unwrap();
2216        cx.background_executor.run_until_parked();
2217        window.update(cx, |_, cx|
2218        search_view.update(cx, |search_view, cx| {
2219                assert_eq!(
2220                    search_view
2221                        .results_editor
2222                        .update(cx, |editor, cx| editor.display_text(cx)),
2223                    "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2224                    "Search view results should match the query"
2225                );
2226                assert!(
2227                    search_view.results_editor.focus_handle(cx).is_focused(cx),
2228                    "Search view with mismatching query should be focused after search results are available",
2229                );
2230            })).unwrap();
2231        cx.spawn(|mut cx| async move {
2232            window
2233                .update(&mut cx, |_, cx| {
2234                    cx.dispatch_action(ToggleFocus.boxed_clone())
2235                })
2236                .unwrap();
2237        })
2238        .detach();
2239        cx.background_executor.run_until_parked();
2240        window.update(cx, |_, cx| {
2241            search_view.update(cx, |search_view, cx| {
2242                    assert!(
2243                        search_view.results_editor.focus_handle(cx).is_focused(cx),
2244                        "Search view with matching query should still have its results editor focused after the toggle focus event",
2245                    );
2246                });
2247        }).unwrap();
2248
2249        workspace
2250            .update(cx, |workspace, cx| {
2251                ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2252            })
2253            .unwrap();
2254        cx.background_executor.run_until_parked();
2255        let Some(search_view_2) = cx.read(|cx| {
2256            workspace
2257                .read(cx)
2258                .unwrap()
2259                .active_pane()
2260                .read(cx)
2261                .active_item()
2262                .and_then(|item| item.downcast::<ProjectSearchView>())
2263        }) else {
2264            panic!("Search view expected to appear after new search event trigger")
2265        };
2266        assert!(
2267            search_view_2 != search_view,
2268            "New search view should be open after `workspace::NewSearch` event"
2269        );
2270
2271        window.update(cx, |_, cx| {
2272            search_view.update(cx, |search_view, cx| {
2273                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO", "First search view should not have an updated query");
2274                    assert_eq!(
2275                        search_view
2276                            .results_editor
2277                            .update(cx, |editor, cx| editor.display_text(cx)),
2278                        "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2279                        "Results of the first search view should not update too"
2280                    );
2281                    assert!(
2282                        !search_view.query_editor.focus_handle(cx).is_focused(cx),
2283                        "Focus should be moved away from the first search view"
2284                    );
2285                });
2286        }).unwrap();
2287
2288        window.update(cx, |_, cx| {
2289            search_view_2.update(cx, |search_view_2, cx| {
2290                    assert_eq!(
2291                        search_view_2.query_editor.read(cx).text(cx),
2292                        "two",
2293                        "New search view should get the query from the text cursor was at during the event spawn (first search view's first result)"
2294                    );
2295                    assert_eq!(
2296                        search_view_2
2297                            .results_editor
2298                            .update(cx, |editor, cx| editor.display_text(cx)),
2299                        "",
2300                        "No search results should be in the 2nd view yet, as we did not spawn a search for it"
2301                    );
2302                    assert!(
2303                        search_view_2.query_editor.focus_handle(cx).is_focused(cx),
2304                        "Focus should be moved into query editor of the new window"
2305                    );
2306                });
2307        }).unwrap();
2308
2309        window
2310            .update(cx, |_, cx| {
2311                search_view_2.update(cx, |search_view_2, cx| {
2312                    search_view_2
2313                        .query_editor
2314                        .update(cx, |query_editor, cx| query_editor.set_text("FOUR", cx));
2315                    search_view_2.search(cx);
2316                });
2317            })
2318            .unwrap();
2319
2320        cx.background_executor.run_until_parked();
2321        window.update(cx, |_, cx| {
2322            search_view_2.update(cx, |search_view_2, cx| {
2323                    assert_eq!(
2324                        search_view_2
2325                            .results_editor
2326                            .update(cx, |editor, cx| editor.display_text(cx)),
2327                        "\n\nconst FOUR: usize = one::ONE + three::THREE;",
2328                        "New search view with the updated query should have new search results"
2329                    );
2330                    assert!(
2331                        search_view_2.results_editor.focus_handle(cx).is_focused(cx),
2332                        "Search view with mismatching query should be focused after search results are available",
2333                    );
2334                });
2335        }).unwrap();
2336
2337        cx.spawn(|mut cx| async move {
2338            window
2339                .update(&mut cx, |_, cx| {
2340                    cx.dispatch_action(ToggleFocus.boxed_clone())
2341                })
2342                .unwrap();
2343        })
2344        .detach();
2345        cx.background_executor.run_until_parked();
2346        window.update(cx, |_, cx| {
2347            search_view_2.update(cx, |search_view_2, cx| {
2348                    assert!(
2349                        search_view_2.results_editor.focus_handle(cx).is_focused(cx),
2350                        "Search view with matching query should switch focus to the results editor after the toggle focus event",
2351                    );
2352                });}).unwrap();
2353    }
2354
2355    #[gpui::test]
2356    async fn test_new_project_search_in_directory(cx: &mut TestAppContext) {
2357        init_test(cx);
2358
2359        let fs = FakeFs::new(cx.background_executor.clone());
2360        fs.insert_tree(
2361            "/dir",
2362            json!({
2363                "a": {
2364                    "one.rs": "const ONE: usize = 1;",
2365                    "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2366                },
2367                "b": {
2368                    "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2369                    "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2370                },
2371            }),
2372        )
2373        .await;
2374        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2375        let worktree_id = project.read_with(cx, |project, cx| {
2376            project.worktrees().next().unwrap().read(cx).id()
2377        });
2378        let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2379        let workspace = window.root(cx).unwrap();
2380        let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
2381
2382        let active_item = cx.read(|cx| {
2383            workspace
2384                .read(cx)
2385                .active_pane()
2386                .read(cx)
2387                .active_item()
2388                .and_then(|item| item.downcast::<ProjectSearchView>())
2389        });
2390        assert!(
2391            active_item.is_none(),
2392            "Expected no search panel to be active"
2393        );
2394
2395        window
2396            .update(cx, move |workspace, cx| {
2397                assert_eq!(workspace.panes().len(), 1);
2398                workspace.panes()[0].update(cx, move |pane, cx| {
2399                    pane.toolbar()
2400                        .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2401                });
2402            })
2403            .unwrap();
2404
2405        let a_dir_entry = cx.update(|cx| {
2406            workspace
2407                .read(cx)
2408                .project()
2409                .read(cx)
2410                .entry_for_path(&(worktree_id, "a").into(), cx)
2411                .expect("no entry for /a/ directory")
2412        });
2413        assert!(a_dir_entry.is_dir());
2414        window
2415            .update(cx, |workspace, cx| {
2416                ProjectSearchView::new_search_in_directory(workspace, &a_dir_entry.path, cx)
2417            })
2418            .unwrap();
2419
2420        let Some(search_view) = cx.read(|cx| {
2421            workspace
2422                .read(cx)
2423                .active_pane()
2424                .read(cx)
2425                .active_item()
2426                .and_then(|item| item.downcast::<ProjectSearchView>())
2427        }) else {
2428            panic!("Search view expected to appear after new search in directory event trigger")
2429        };
2430        cx.background_executor.run_until_parked();
2431        window
2432            .update(cx, |_, cx| {
2433                search_view.update(cx, |search_view, cx| {
2434                    assert!(
2435                        search_view.query_editor.focus_handle(cx).is_focused(cx),
2436                        "On new search in directory, focus should be moved into query editor"
2437                    );
2438                    search_view.excluded_files_editor.update(cx, |editor, cx| {
2439                        assert!(
2440                            editor.display_text(cx).is_empty(),
2441                            "New search in directory should not have any excluded files"
2442                        );
2443                    });
2444                    search_view.included_files_editor.update(cx, |editor, cx| {
2445                        assert_eq!(
2446                            editor.display_text(cx),
2447                            a_dir_entry.path.to_str().unwrap(),
2448                            "New search in directory should have included dir entry path"
2449                        );
2450                    });
2451                });
2452            })
2453            .unwrap();
2454        window
2455            .update(cx, |_, cx| {
2456                search_view.update(cx, |search_view, cx| {
2457                    search_view
2458                        .query_editor
2459                        .update(cx, |query_editor, cx| query_editor.set_text("const", cx));
2460                    search_view.search(cx);
2461                });
2462            })
2463            .unwrap();
2464        cx.background_executor.run_until_parked();
2465        window
2466            .update(cx, |_, cx| {
2467                search_view.update(cx, |search_view, cx| {
2468                    assert_eq!(
2469                search_view
2470                    .results_editor
2471                    .update(cx, |editor, cx| editor.display_text(cx)),
2472                "\n\nconst ONE: usize = 1;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2473                "New search in directory should have a filter that matches a certain directory"
2474            );
2475                })
2476            })
2477            .unwrap();
2478    }
2479
2480    #[gpui::test]
2481    async fn test_search_query_history(cx: &mut TestAppContext) {
2482        init_test(cx);
2483
2484        let fs = FakeFs::new(cx.background_executor.clone());
2485        fs.insert_tree(
2486            "/dir",
2487            json!({
2488                "one.rs": "const ONE: usize = 1;",
2489                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2490                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2491                "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2492            }),
2493        )
2494        .await;
2495        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2496        let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2497        let workspace = window.root(cx).unwrap();
2498        let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
2499
2500        window
2501            .update(cx, {
2502                let search_bar = search_bar.clone();
2503                move |workspace, cx| {
2504                    assert_eq!(workspace.panes().len(), 1);
2505                    workspace.panes()[0].update(cx, move |pane, cx| {
2506                        pane.toolbar()
2507                            .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2508                    });
2509
2510                    ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2511                }
2512            })
2513            .unwrap();
2514
2515        let search_view = cx.read(|cx| {
2516            workspace
2517                .read(cx)
2518                .active_pane()
2519                .read(cx)
2520                .active_item()
2521                .and_then(|item| item.downcast::<ProjectSearchView>())
2522                .expect("Search view expected to appear after new search event trigger")
2523        });
2524
2525        // Add 3 search items into the history + another unsubmitted one.
2526        window
2527            .update(cx, |_, cx| {
2528                search_view.update(cx, |search_view, cx| {
2529                    search_view.search_options = SearchOptions::CASE_SENSITIVE;
2530                    search_view
2531                        .query_editor
2532                        .update(cx, |query_editor, cx| query_editor.set_text("ONE", cx));
2533                    search_view.search(cx);
2534                });
2535            })
2536            .unwrap();
2537
2538        cx.background_executor.run_until_parked();
2539        window
2540            .update(cx, |_, cx| {
2541                search_view.update(cx, |search_view, cx| {
2542                    search_view
2543                        .query_editor
2544                        .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
2545                    search_view.search(cx);
2546                });
2547            })
2548            .unwrap();
2549        cx.background_executor.run_until_parked();
2550        window
2551            .update(cx, |_, cx| {
2552                search_view.update(cx, |search_view, cx| {
2553                    search_view
2554                        .query_editor
2555                        .update(cx, |query_editor, cx| query_editor.set_text("THREE", cx));
2556                    search_view.search(cx);
2557                })
2558            })
2559            .unwrap();
2560        cx.background_executor.run_until_parked();
2561        window
2562            .update(cx, |_, cx| {
2563                search_view.update(cx, |search_view, cx| {
2564                    search_view.query_editor.update(cx, |query_editor, cx| {
2565                        query_editor.set_text("JUST_TEXT_INPUT", cx)
2566                    });
2567                })
2568            })
2569            .unwrap();
2570        cx.background_executor.run_until_parked();
2571
2572        // Ensure that the latest input with search settings is active.
2573        window
2574            .update(cx, |_, cx| {
2575                search_view.update(cx, |search_view, cx| {
2576                    assert_eq!(
2577                        search_view.query_editor.read(cx).text(cx),
2578                        "JUST_TEXT_INPUT"
2579                    );
2580                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2581                });
2582            })
2583            .unwrap();
2584
2585        // Next history query after the latest should set the query to the empty string.
2586        window
2587            .update(cx, |_, cx| {
2588                search_bar.update(cx, |search_bar, cx| {
2589                    search_bar.next_history_query(&NextHistoryQuery, cx);
2590                })
2591            })
2592            .unwrap();
2593        window
2594            .update(cx, |_, cx| {
2595                search_view.update(cx, |search_view, cx| {
2596                    assert_eq!(search_view.query_editor.read(cx).text(cx), "");
2597                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2598                });
2599            })
2600            .unwrap();
2601        window
2602            .update(cx, |_, cx| {
2603                search_bar.update(cx, |search_bar, cx| {
2604                    search_bar.next_history_query(&NextHistoryQuery, cx);
2605                })
2606            })
2607            .unwrap();
2608        window
2609            .update(cx, |_, cx| {
2610                search_view.update(cx, |search_view, cx| {
2611                    assert_eq!(search_view.query_editor.read(cx).text(cx), "");
2612                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2613                });
2614            })
2615            .unwrap();
2616
2617        // First previous query for empty current query should set the query to the latest submitted one.
2618        window
2619            .update(cx, |_, cx| {
2620                search_bar.update(cx, |search_bar, cx| {
2621                    search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2622                });
2623            })
2624            .unwrap();
2625        window
2626            .update(cx, |_, cx| {
2627                search_view.update(cx, |search_view, cx| {
2628                    assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
2629                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2630                });
2631            })
2632            .unwrap();
2633
2634        // Further previous items should go over the history in reverse order.
2635        window
2636            .update(cx, |_, cx| {
2637                search_bar.update(cx, |search_bar, cx| {
2638                    search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2639                });
2640            })
2641            .unwrap();
2642        window
2643            .update(cx, |_, cx| {
2644                search_view.update(cx, |search_view, cx| {
2645                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
2646                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2647                });
2648            })
2649            .unwrap();
2650
2651        // Previous items should never go behind the first history item.
2652        window
2653            .update(cx, |_, cx| {
2654                search_bar.update(cx, |search_bar, cx| {
2655                    search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2656                });
2657            })
2658            .unwrap();
2659        window
2660            .update(cx, |_, cx| {
2661                search_view.update(cx, |search_view, cx| {
2662                    assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
2663                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2664                });
2665            })
2666            .unwrap();
2667        window
2668            .update(cx, |_, cx| {
2669                search_bar.update(cx, |search_bar, cx| {
2670                    search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2671                });
2672            })
2673            .unwrap();
2674        window
2675            .update(cx, |_, cx| {
2676                search_view.update(cx, |search_view, cx| {
2677                    assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
2678                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2679                });
2680            })
2681            .unwrap();
2682
2683        // Next items should go over the history in the original order.
2684        window
2685            .update(cx, |_, cx| {
2686                search_bar.update(cx, |search_bar, cx| {
2687                    search_bar.next_history_query(&NextHistoryQuery, cx);
2688                });
2689            })
2690            .unwrap();
2691        window
2692            .update(cx, |_, cx| {
2693                search_view.update(cx, |search_view, cx| {
2694                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
2695                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2696                });
2697            })
2698            .unwrap();
2699
2700        window
2701            .update(cx, |_, cx| {
2702                search_view.update(cx, |search_view, cx| {
2703                    search_view
2704                        .query_editor
2705                        .update(cx, |query_editor, cx| query_editor.set_text("TWO_NEW", cx));
2706                    search_view.search(cx);
2707                });
2708            })
2709            .unwrap();
2710        cx.background_executor.run_until_parked();
2711        window
2712            .update(cx, |_, cx| {
2713                search_view.update(cx, |search_view, cx| {
2714                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
2715                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2716                });
2717            })
2718            .unwrap();
2719
2720        // New search input should add another entry to history and move the selection to the end of the history.
2721        window
2722            .update(cx, |_, cx| {
2723                search_bar.update(cx, |search_bar, cx| {
2724                    search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2725                });
2726            })
2727            .unwrap();
2728        window
2729            .update(cx, |_, cx| {
2730                search_view.update(cx, |search_view, cx| {
2731                    assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
2732                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2733                });
2734            })
2735            .unwrap();
2736        window
2737            .update(cx, |_, cx| {
2738                search_bar.update(cx, |search_bar, cx| {
2739                    search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2740                });
2741            })
2742            .unwrap();
2743        window
2744            .update(cx, |_, cx| {
2745                search_view.update(cx, |search_view, cx| {
2746                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
2747                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2748                });
2749            })
2750            .unwrap();
2751        window
2752            .update(cx, |_, cx| {
2753                search_bar.update(cx, |search_bar, cx| {
2754                    search_bar.next_history_query(&NextHistoryQuery, cx);
2755                });
2756            })
2757            .unwrap();
2758        window
2759            .update(cx, |_, cx| {
2760                search_view.update(cx, |search_view, cx| {
2761                    assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
2762                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2763                });
2764            })
2765            .unwrap();
2766        window
2767            .update(cx, |_, cx| {
2768                search_bar.update(cx, |search_bar, cx| {
2769                    search_bar.next_history_query(&NextHistoryQuery, cx);
2770                });
2771            })
2772            .unwrap();
2773        window
2774            .update(cx, |_, cx| {
2775                search_view.update(cx, |search_view, cx| {
2776                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
2777                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2778                });
2779            })
2780            .unwrap();
2781        window
2782            .update(cx, |_, cx| {
2783                search_bar.update(cx, |search_bar, cx| {
2784                    search_bar.next_history_query(&NextHistoryQuery, cx);
2785                });
2786            })
2787            .unwrap();
2788        window
2789            .update(cx, |_, cx| {
2790                search_view.update(cx, |search_view, cx| {
2791                    assert_eq!(search_view.query_editor.read(cx).text(cx), "");
2792                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2793                });
2794            })
2795            .unwrap();
2796    }
2797
2798    #[gpui::test]
2799    async fn test_search_query_history_with_multiple_views(cx: &mut TestAppContext) {
2800        init_test(cx);
2801
2802        let fs = FakeFs::new(cx.background_executor.clone());
2803        fs.insert_tree(
2804            "/dir",
2805            json!({
2806                "one.rs": "const ONE: usize = 1;",
2807            }),
2808        )
2809        .await;
2810        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2811        let worktree_id = project.update(cx, |this, cx| {
2812            this.worktrees().next().unwrap().read(cx).id()
2813        });
2814
2815        let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2816        let workspace = window.root(cx).unwrap();
2817
2818        let panes: Vec<_> = window
2819            .update(cx, |this, _| this.panes().to_owned())
2820            .unwrap();
2821
2822        let search_bar_1 = window.build_view(cx, |_| ProjectSearchBar::new());
2823        let search_bar_2 = window.build_view(cx, |_| ProjectSearchBar::new());
2824
2825        assert_eq!(panes.len(), 1);
2826        let first_pane = panes.get(0).cloned().unwrap();
2827        assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 0);
2828        window
2829            .update(cx, |workspace, cx| {
2830                workspace.open_path(
2831                    (worktree_id, "one.rs"),
2832                    Some(first_pane.downgrade()),
2833                    true,
2834                    cx,
2835                )
2836            })
2837            .unwrap()
2838            .await
2839            .unwrap();
2840        assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
2841
2842        // Add a project search item to the first pane
2843        window
2844            .update(cx, {
2845                let search_bar = search_bar_1.clone();
2846                let pane = first_pane.clone();
2847                move |workspace, cx| {
2848                    pane.update(cx, move |pane, cx| {
2849                        pane.toolbar()
2850                            .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2851                    });
2852
2853                    ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2854                }
2855            })
2856            .unwrap();
2857        let search_view_1 = cx.read(|cx| {
2858            workspace
2859                .read(cx)
2860                .active_item(cx)
2861                .and_then(|item| item.downcast::<ProjectSearchView>())
2862                .expect("Search view expected to appear after new search event trigger")
2863        });
2864
2865        let second_pane = window
2866            .update(cx, |workspace, cx| {
2867                workspace.split_and_clone(first_pane.clone(), workspace::SplitDirection::Right, cx)
2868            })
2869            .unwrap()
2870            .unwrap();
2871        assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
2872
2873        assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
2874        assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 2);
2875
2876        // Add a project search item to the second pane
2877        window
2878            .update(cx, {
2879                let search_bar = search_bar_2.clone();
2880                let pane = second_pane.clone();
2881                move |workspace, cx| {
2882                    assert_eq!(workspace.panes().len(), 2);
2883                    pane.update(cx, move |pane, cx| {
2884                        pane.toolbar()
2885                            .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2886                    });
2887
2888                    ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2889                }
2890            })
2891            .unwrap();
2892
2893        let search_view_2 = cx.read(|cx| {
2894            workspace
2895                .read(cx)
2896                .active_item(cx)
2897                .and_then(|item| item.downcast::<ProjectSearchView>())
2898                .expect("Search view expected to appear after new search event trigger")
2899        });
2900
2901        cx.run_until_parked();
2902        assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 2);
2903        assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 2);
2904
2905        let update_search_view =
2906            |search_view: &View<ProjectSearchView>, query: &str, cx: &mut TestAppContext| {
2907                window
2908                    .update(cx, |_, cx| {
2909                        search_view.update(cx, |search_view, cx| {
2910                            search_view
2911                                .query_editor
2912                                .update(cx, |query_editor, cx| query_editor.set_text(query, cx));
2913                            search_view.search(cx);
2914                        });
2915                    })
2916                    .unwrap();
2917            };
2918
2919        let active_query =
2920            |search_view: &View<ProjectSearchView>, cx: &mut TestAppContext| -> String {
2921                window
2922                    .update(cx, |_, cx| {
2923                        search_view.update(cx, |search_view, cx| {
2924                            search_view.query_editor.read(cx).text(cx).to_string()
2925                        })
2926                    })
2927                    .unwrap()
2928            };
2929
2930        let select_prev_history_item =
2931            |search_bar: &View<ProjectSearchBar>, cx: &mut TestAppContext| {
2932                window
2933                    .update(cx, |_, cx| {
2934                        search_bar.update(cx, |search_bar, cx| {
2935                            search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2936                        })
2937                    })
2938                    .unwrap();
2939            };
2940
2941        let select_next_history_item =
2942            |search_bar: &View<ProjectSearchBar>, cx: &mut TestAppContext| {
2943                window
2944                    .update(cx, |_, cx| {
2945                        search_bar.update(cx, |search_bar, cx| {
2946                            search_bar.next_history_query(&NextHistoryQuery, cx);
2947                        })
2948                    })
2949                    .unwrap();
2950            };
2951
2952        update_search_view(&search_view_1, "ONE", cx);
2953        cx.background_executor.run_until_parked();
2954
2955        update_search_view(&search_view_2, "TWO", cx);
2956        cx.background_executor.run_until_parked();
2957
2958        assert_eq!(active_query(&search_view_1, cx), "ONE");
2959        assert_eq!(active_query(&search_view_2, cx), "TWO");
2960
2961        // Selecting previous history item should select the query from search view 1.
2962        select_prev_history_item(&search_bar_2, cx);
2963        assert_eq!(active_query(&search_view_2, cx), "ONE");
2964
2965        // Selecting the previous history item should not change the query as it is already the first item.
2966        select_prev_history_item(&search_bar_2, cx);
2967        assert_eq!(active_query(&search_view_2, cx), "ONE");
2968
2969        // Changing the query in search view 2 should not affect the history of search view 1.
2970        assert_eq!(active_query(&search_view_1, cx), "ONE");
2971
2972        // Deploying a new search in search view 2
2973        update_search_view(&search_view_2, "THREE", cx);
2974        cx.background_executor.run_until_parked();
2975
2976        select_next_history_item(&search_bar_2, cx);
2977        assert_eq!(active_query(&search_view_2, cx), "");
2978
2979        select_prev_history_item(&search_bar_2, cx);
2980        assert_eq!(active_query(&search_view_2, cx), "THREE");
2981
2982        select_prev_history_item(&search_bar_2, cx);
2983        assert_eq!(active_query(&search_view_2, cx), "TWO");
2984
2985        select_prev_history_item(&search_bar_2, cx);
2986        assert_eq!(active_query(&search_view_2, cx), "ONE");
2987
2988        select_prev_history_item(&search_bar_2, cx);
2989        assert_eq!(active_query(&search_view_2, cx), "ONE");
2990
2991        // Search view 1 should now see the query from search view 2.
2992        assert_eq!(active_query(&search_view_1, cx), "ONE");
2993
2994        select_next_history_item(&search_bar_2, cx);
2995        assert_eq!(active_query(&search_view_2, cx), "TWO");
2996
2997        // Here is the new query from search view 2
2998        select_next_history_item(&search_bar_2, cx);
2999        assert_eq!(active_query(&search_view_2, cx), "THREE");
3000
3001        select_next_history_item(&search_bar_2, cx);
3002        assert_eq!(active_query(&search_view_2, cx), "");
3003
3004        select_next_history_item(&search_bar_1, cx);
3005        assert_eq!(active_query(&search_view_1, cx), "TWO");
3006
3007        select_next_history_item(&search_bar_1, cx);
3008        assert_eq!(active_query(&search_view_1, cx), "THREE");
3009
3010        select_next_history_item(&search_bar_1, cx);
3011        assert_eq!(active_query(&search_view_1, cx), "");
3012    }
3013
3014    #[gpui::test]
3015    async fn test_deploy_search_with_multiple_panes(cx: &mut TestAppContext) {
3016        init_test(cx);
3017
3018        // Setup 2 panes, both with a file open and one with a project search.
3019        let fs = FakeFs::new(cx.background_executor.clone());
3020        fs.insert_tree(
3021            "/dir",
3022            json!({
3023                "one.rs": "const ONE: usize = 1;",
3024            }),
3025        )
3026        .await;
3027        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
3028        let worktree_id = project.update(cx, |this, cx| {
3029            this.worktrees().next().unwrap().read(cx).id()
3030        });
3031        let window = cx.add_window(|cx| Workspace::test_new(project, cx));
3032        let panes: Vec<_> = window
3033            .update(cx, |this, _| this.panes().to_owned())
3034            .unwrap();
3035        assert_eq!(panes.len(), 1);
3036        let first_pane = panes.get(0).cloned().unwrap();
3037        assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 0);
3038        window
3039            .update(cx, |workspace, cx| {
3040                workspace.open_path(
3041                    (worktree_id, "one.rs"),
3042                    Some(first_pane.downgrade()),
3043                    true,
3044                    cx,
3045                )
3046            })
3047            .unwrap()
3048            .await
3049            .unwrap();
3050        assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3051        let second_pane = window
3052            .update(cx, |workspace, cx| {
3053                workspace.split_and_clone(first_pane.clone(), workspace::SplitDirection::Right, cx)
3054            })
3055            .unwrap()
3056            .unwrap();
3057        assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
3058        assert!(window
3059            .update(cx, |_, cx| second_pane
3060                .focus_handle(cx)
3061                .contains_focused(cx))
3062            .unwrap());
3063        let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
3064        window
3065            .update(cx, {
3066                let search_bar = search_bar.clone();
3067                let pane = first_pane.clone();
3068                move |workspace, cx| {
3069                    assert_eq!(workspace.panes().len(), 2);
3070                    pane.update(cx, move |pane, cx| {
3071                        pane.toolbar()
3072                            .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
3073                    });
3074                }
3075            })
3076            .unwrap();
3077
3078        // Add a project search item to the second pane
3079        window
3080            .update(cx, {
3081                let search_bar = search_bar.clone();
3082                let pane = second_pane.clone();
3083                move |workspace, cx| {
3084                    assert_eq!(workspace.panes().len(), 2);
3085                    pane.update(cx, move |pane, cx| {
3086                        pane.toolbar()
3087                            .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
3088                    });
3089
3090                    ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
3091                }
3092            })
3093            .unwrap();
3094
3095        cx.run_until_parked();
3096        assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 2);
3097        assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3098
3099        // Focus the first pane
3100        window
3101            .update(cx, |workspace, cx| {
3102                assert_eq!(workspace.active_pane(), &second_pane);
3103                second_pane.update(cx, |this, cx| {
3104                    assert_eq!(this.active_item_index(), 1);
3105                    this.activate_prev_item(false, cx);
3106                    assert_eq!(this.active_item_index(), 0);
3107                });
3108                workspace.activate_pane_in_direction(workspace::SplitDirection::Left, cx);
3109            })
3110            .unwrap();
3111        window
3112            .update(cx, |workspace, cx| {
3113                assert_eq!(workspace.active_pane(), &first_pane);
3114                assert_eq!(first_pane.read(cx).items_len(), 1);
3115                assert_eq!(second_pane.read(cx).items_len(), 2);
3116            })
3117            .unwrap();
3118
3119        // Deploy a new search
3120        cx.dispatch_action(window.into(), DeploySearch::find());
3121
3122        // Both panes should now have a project search in them
3123        window
3124            .update(cx, |workspace, cx| {
3125                assert_eq!(workspace.active_pane(), &first_pane);
3126                first_pane.update(cx, |this, _| {
3127                    assert_eq!(this.active_item_index(), 1);
3128                    assert_eq!(this.items_len(), 2);
3129                });
3130                second_pane.update(cx, |this, cx| {
3131                    assert!(!cx.focus_handle().contains_focused(cx));
3132                    assert_eq!(this.items_len(), 2);
3133                });
3134            })
3135            .unwrap();
3136
3137        // Focus the second pane's non-search item
3138        window
3139            .update(cx, |_workspace, cx| {
3140                second_pane.update(cx, |pane, cx| pane.activate_next_item(true, cx));
3141            })
3142            .unwrap();
3143
3144        // Deploy a new search
3145        cx.dispatch_action(window.into(), DeploySearch::find());
3146
3147        // The project search view should now be focused in the second pane
3148        // And the number of items should be unchanged.
3149        window
3150            .update(cx, |_workspace, cx| {
3151                second_pane.update(cx, |pane, _cx| {
3152                    assert!(pane
3153                        .active_item()
3154                        .unwrap()
3155                        .downcast::<ProjectSearchView>()
3156                        .is_some());
3157
3158                    assert_eq!(pane.items_len(), 2);
3159                });
3160            })
3161            .unwrap();
3162    }
3163
3164    #[gpui::test]
3165    async fn test_scroll_search_results_to_top(cx: &mut TestAppContext) {
3166        init_test(cx);
3167
3168        // We need many lines in the search results to be able to scroll the window
3169        let fs = FakeFs::new(cx.background_executor.clone());
3170        fs.insert_tree(
3171            "/dir",
3172            json!({
3173                "1.txt": "\n\n\n\n\n A \n\n\n\n\n",
3174                "2.txt": "\n\n\n\n\n A \n\n\n\n\n",
3175                "3.rs": "\n\n\n\n\n A \n\n\n\n\n",
3176                "4.rs": "\n\n\n\n\n A \n\n\n\n\n",
3177                "5.rs": "\n\n\n\n\n A \n\n\n\n\n",
3178                "6.rs": "\n\n\n\n\n A \n\n\n\n\n",
3179                "7.rs": "\n\n\n\n\n A \n\n\n\n\n",
3180                "8.rs": "\n\n\n\n\n A \n\n\n\n\n",
3181                "9.rs": "\n\n\n\n\n A \n\n\n\n\n",
3182                "a.rs": "\n\n\n\n\n A \n\n\n\n\n",
3183                "b.rs": "\n\n\n\n\n B \n\n\n\n\n",
3184                "c.rs": "\n\n\n\n\n B \n\n\n\n\n",
3185                "d.rs": "\n\n\n\n\n B \n\n\n\n\n",
3186                "e.rs": "\n\n\n\n\n B \n\n\n\n\n",
3187                "f.rs": "\n\n\n\n\n B \n\n\n\n\n",
3188                "g.rs": "\n\n\n\n\n B \n\n\n\n\n",
3189                "h.rs": "\n\n\n\n\n B \n\n\n\n\n",
3190                "i.rs": "\n\n\n\n\n B \n\n\n\n\n",
3191                "j.rs": "\n\n\n\n\n B \n\n\n\n\n",
3192                "k.rs": "\n\n\n\n\n B \n\n\n\n\n",
3193            }),
3194        )
3195        .await;
3196        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
3197        let search = cx.new_model(|cx| ProjectSearch::new(project, cx));
3198        let search_view = cx.add_window(|cx| ProjectSearchView::new(search.clone(), cx, None));
3199
3200        // First search
3201        perform_search(search_view, "A", cx);
3202        search_view
3203            .update(cx, |search_view, cx| {
3204                search_view.results_editor.update(cx, |results_editor, cx| {
3205                    // Results are correct and scrolled to the top
3206                    assert_eq!(
3207                        results_editor.display_text(cx).match_indices(" A ").count(),
3208                        10
3209                    );
3210                    assert_eq!(results_editor.scroll_position(cx), Point::default());
3211
3212                    // Scroll results all the way down
3213                    results_editor.scroll(Point::new(0., f32::MAX), Some(Axis::Vertical), cx);
3214                });
3215            })
3216            .expect("unable to update search view");
3217
3218        // Second search
3219        perform_search(search_view, "B", cx);
3220        search_view
3221            .update(cx, |search_view, cx| {
3222                search_view.results_editor.update(cx, |results_editor, cx| {
3223                    // Results are correct...
3224                    assert_eq!(
3225                        results_editor.display_text(cx).match_indices(" B ").count(),
3226                        10
3227                    );
3228                    // ...and scrolled back to the top
3229                    assert_eq!(results_editor.scroll_position(cx), Point::default());
3230                });
3231            })
3232            .expect("unable to update search view");
3233    }
3234
3235    fn init_test(cx: &mut TestAppContext) {
3236        cx.update(|cx| {
3237            let settings = SettingsStore::test(cx);
3238            cx.set_global(settings);
3239
3240            theme::init(theme::LoadThemes::JustBase, cx);
3241
3242            language::init(cx);
3243            client::init_settings(cx);
3244            editor::init(cx);
3245            workspace::init_settings(cx);
3246            Project::init_settings(cx);
3247            super::init(cx);
3248        });
3249    }
3250
3251    fn perform_search(
3252        search_view: WindowHandle<ProjectSearchView>,
3253        text: impl Into<Arc<str>>,
3254        cx: &mut TestAppContext,
3255    ) {
3256        search_view
3257            .update(cx, |search_view, cx| {
3258                search_view
3259                    .query_editor
3260                    .update(cx, |query_editor, cx| query_editor.set_text(text, cx));
3261                search_view.search(cx);
3262            })
3263            .unwrap();
3264        cx.background_executor.run_until_parked();
3265    }
3266}