project_search.rs

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