project_search.rs

   1use crate::{
   2    SearchOption, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleRegex,
   3    ToggleWholeWord,
   4};
   5use collections::HashMap;
   6use editor::{
   7    items::active_match_index, scroll::autoscroll::Autoscroll, Anchor, Editor, MultiBuffer,
   8    SelectAll, MAX_TAB_TITLE_LEN,
   9};
  10use futures::StreamExt;
  11use gpui::{
  12    actions,
  13    elements::*,
  14    platform::{CursorStyle, MouseButton},
  15    Action, AnyElement, AnyViewHandle, AppContext, Entity, ModelContext, ModelHandle, Subscription,
  16    Task, View, ViewContext, ViewHandle, WeakModelHandle, WeakViewHandle,
  17};
  18use menu::Confirm;
  19use project::{search::SearchQuery, Project};
  20use settings::Settings;
  21use smallvec::SmallVec;
  22use std::{
  23    any::{Any, TypeId},
  24    borrow::Cow,
  25    mem,
  26    ops::Range,
  27    path::PathBuf,
  28    sync::Arc,
  29};
  30use util::ResultExt as _;
  31use workspace::{
  32    item::{BreadcrumbText, Item, ItemEvent, ItemHandle},
  33    searchable::{Direction, SearchableItem, SearchableItemHandle},
  34    ItemNavHistory, Pane, ToolbarItemLocation, ToolbarItemView, Workspace, WorkspaceId,
  35};
  36
  37actions!(project_search, [SearchInNew, ToggleFocus]);
  38
  39#[derive(Default)]
  40struct ActiveSearches(HashMap<WeakModelHandle<Project>, WeakViewHandle<ProjectSearchView>>);
  41
  42pub fn init(cx: &mut AppContext) {
  43    cx.set_global(ActiveSearches::default());
  44    cx.add_action(ProjectSearchView::deploy);
  45    cx.add_action(ProjectSearchBar::search);
  46    cx.add_action(ProjectSearchBar::search_in_new);
  47    cx.add_action(ProjectSearchBar::select_next_match);
  48    cx.add_action(ProjectSearchBar::select_prev_match);
  49    cx.add_action(ProjectSearchBar::toggle_focus);
  50    cx.capture_action(ProjectSearchBar::tab);
  51    add_toggle_option_action::<ToggleCaseSensitive>(SearchOption::CaseSensitive, cx);
  52    add_toggle_option_action::<ToggleWholeWord>(SearchOption::WholeWord, cx);
  53    add_toggle_option_action::<ToggleRegex>(SearchOption::Regex, cx);
  54}
  55
  56fn add_toggle_option_action<A: Action>(option: SearchOption, cx: &mut AppContext) {
  57    cx.add_action(move |pane: &mut Pane, _: &A, cx: &mut ViewContext<Pane>| {
  58        if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<ProjectSearchBar>() {
  59            if search_bar.update(cx, |search_bar, cx| {
  60                search_bar.toggle_search_option(option, cx)
  61            }) {
  62                return;
  63            }
  64        }
  65        cx.propagate_action();
  66    });
  67}
  68
  69struct ProjectSearch {
  70    project: ModelHandle<Project>,
  71    excerpts: ModelHandle<MultiBuffer>,
  72    pending_search: Option<Task<Option<()>>>,
  73    match_ranges: Vec<Range<Anchor>>,
  74    active_query: Option<SearchQuery>,
  75    search_id: usize,
  76}
  77
  78pub struct ProjectSearchView {
  79    model: ModelHandle<ProjectSearch>,
  80    query_editor: ViewHandle<Editor>,
  81    results_editor: ViewHandle<Editor>,
  82    case_sensitive: bool,
  83    whole_word: bool,
  84    regex: bool,
  85    query_contains_error: bool,
  86    active_match_index: Option<usize>,
  87    search_id: usize,
  88}
  89
  90pub struct ProjectSearchBar {
  91    active_project_search: Option<ViewHandle<ProjectSearchView>>,
  92    subscription: Option<Subscription>,
  93}
  94
  95impl Entity for ProjectSearch {
  96    type Event = ();
  97}
  98
  99impl ProjectSearch {
 100    fn new(project: ModelHandle<Project>, cx: &mut ModelContext<Self>) -> Self {
 101        let replica_id = project.read(cx).replica_id();
 102        Self {
 103            project,
 104            excerpts: cx.add_model(|_| MultiBuffer::new(replica_id)),
 105            pending_search: Default::default(),
 106            match_ranges: Default::default(),
 107            active_query: None,
 108            search_id: 0,
 109        }
 110    }
 111
 112    fn clone(&self, cx: &mut ModelContext<Self>) -> ModelHandle<Self> {
 113        cx.add_model(|cx| Self {
 114            project: self.project.clone(),
 115            excerpts: self
 116                .excerpts
 117                .update(cx, |excerpts, cx| cx.add_model(|cx| excerpts.clone(cx))),
 118            pending_search: Default::default(),
 119            match_ranges: self.match_ranges.clone(),
 120            active_query: self.active_query.clone(),
 121            search_id: self.search_id,
 122        })
 123    }
 124
 125    fn search(&mut self, query: SearchQuery, cx: &mut ModelContext<Self>) {
 126        let search = self
 127            .project
 128            .update(cx, |project, cx| project.search(query.clone(), cx));
 129        self.search_id += 1;
 130        self.active_query = Some(query);
 131        self.match_ranges.clear();
 132        self.pending_search = Some(cx.spawn_weak(|this, mut cx| async move {
 133            let matches = search.await.log_err()?;
 134            let this = this.upgrade(&cx)?;
 135            let mut matches = matches.into_iter().collect::<Vec<_>>();
 136            let (_task, mut match_ranges) = this.update(&mut cx, |this, cx| {
 137                this.match_ranges.clear();
 138                matches.sort_by_key(|(buffer, _)| buffer.read(cx).file().map(|file| file.path()));
 139                this.excerpts.update(cx, |excerpts, cx| {
 140                    excerpts.clear(cx);
 141                    excerpts.stream_excerpts_with_context_lines(matches, 1, cx)
 142                })
 143            });
 144
 145            while let Some(match_range) = match_ranges.next().await {
 146                this.update(&mut cx, |this, cx| {
 147                    this.match_ranges.push(match_range);
 148                    while let Ok(Some(match_range)) = match_ranges.try_next() {
 149                        this.match_ranges.push(match_range);
 150                    }
 151                    cx.notify();
 152                });
 153            }
 154
 155            this.update(&mut cx, |this, cx| {
 156                this.pending_search.take();
 157                cx.notify();
 158            });
 159
 160            None
 161        }));
 162        cx.notify();
 163    }
 164}
 165
 166pub enum ViewEvent {
 167    UpdateTab,
 168    Activate,
 169    EditorEvent(editor::Event),
 170}
 171
 172impl Entity for ProjectSearchView {
 173    type Event = ViewEvent;
 174}
 175
 176impl View for ProjectSearchView {
 177    fn ui_name() -> &'static str {
 178        "ProjectSearchView"
 179    }
 180
 181    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
 182        let model = &self.model.read(cx);
 183        if model.match_ranges.is_empty() {
 184            enum Status {}
 185
 186            let theme = cx.global::<Settings>().theme.clone();
 187            let text = if self.query_editor.read(cx).text(cx).is_empty() {
 188                ""
 189            } else if model.pending_search.is_some() {
 190                "Searching..."
 191            } else {
 192                "No results"
 193            };
 194            MouseEventHandler::<Status, _>::new(0, cx, |_, _| {
 195                Label::new(text, theme.search.results_status.clone())
 196                    .aligned()
 197                    .contained()
 198                    .with_background_color(theme.editor.background)
 199                    .flex(1., true)
 200            })
 201            .on_down(MouseButton::Left, |_, _, cx| {
 202                cx.focus_parent_view();
 203            })
 204            .into_any_named("project search view")
 205        } else {
 206            ChildView::new(&self.results_editor, cx)
 207                .flex(1., true)
 208                .into_any_named("project search view")
 209        }
 210    }
 211
 212    fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
 213        let handle = cx.weak_handle();
 214        cx.update_global(|state: &mut ActiveSearches, cx| {
 215            state
 216                .0
 217                .insert(self.model.read(cx).project.downgrade(), handle)
 218        });
 219
 220        if cx.is_self_focused() {
 221            self.focus_query_editor(cx);
 222        }
 223    }
 224}
 225
 226impl Item for ProjectSearchView {
 227    fn tab_tooltip_text(&self, cx: &AppContext) -> Option<Cow<str>> {
 228        Some(self.query_editor.read(cx).text(cx).into())
 229    }
 230
 231    fn act_as_type<'a>(
 232        &'a self,
 233        type_id: TypeId,
 234        self_handle: &'a ViewHandle<Self>,
 235        _: &'a AppContext,
 236    ) -> Option<&'a AnyViewHandle> {
 237        if type_id == TypeId::of::<Self>() {
 238            Some(self_handle)
 239        } else if type_id == TypeId::of::<Editor>() {
 240            Some(&self.results_editor)
 241        } else {
 242            None
 243        }
 244    }
 245
 246    fn deactivated(&mut self, cx: &mut ViewContext<Self>) {
 247        self.results_editor
 248            .update(cx, |editor, cx| editor.deactivated(cx));
 249    }
 250
 251    fn tab_content<T: View>(
 252        &self,
 253        _detail: Option<usize>,
 254        tab_theme: &theme::Tab,
 255        cx: &AppContext,
 256    ) -> AnyElement<T> {
 257        Flex::row()
 258            .with_child(
 259                Svg::new("icons/magnifying_glass_12.svg")
 260                    .with_color(tab_theme.label.text.color)
 261                    .constrained()
 262                    .with_width(tab_theme.type_icon_width)
 263                    .aligned()
 264                    .contained()
 265                    .with_margin_right(tab_theme.spacing),
 266            )
 267            .with_children(self.model.read(cx).active_query.as_ref().map(|query| {
 268                let query_text = util::truncate_and_trailoff(query.as_str(), MAX_TAB_TITLE_LEN);
 269
 270                Label::new(query_text, tab_theme.label.clone()).aligned()
 271            }))
 272            .into_any()
 273    }
 274
 275    fn for_each_project_item(&self, cx: &AppContext, f: &mut dyn FnMut(usize, &dyn project::Item)) {
 276        self.results_editor.for_each_project_item(cx, f)
 277    }
 278
 279    fn is_singleton(&self, _: &AppContext) -> bool {
 280        false
 281    }
 282
 283    fn can_save(&self, _: &AppContext) -> bool {
 284        true
 285    }
 286
 287    fn is_dirty(&self, cx: &AppContext) -> bool {
 288        self.results_editor.read(cx).is_dirty(cx)
 289    }
 290
 291    fn has_conflict(&self, cx: &AppContext) -> bool {
 292        self.results_editor.read(cx).has_conflict(cx)
 293    }
 294
 295    fn save(
 296        &mut self,
 297        project: ModelHandle<Project>,
 298        cx: &mut ViewContext<Self>,
 299    ) -> Task<anyhow::Result<()>> {
 300        self.results_editor
 301            .update(cx, |editor, cx| editor.save(project, cx))
 302    }
 303
 304    fn save_as(
 305        &mut self,
 306        _: ModelHandle<Project>,
 307        _: PathBuf,
 308        _: &mut ViewContext<Self>,
 309    ) -> Task<anyhow::Result<()>> {
 310        unreachable!("save_as should not have been called")
 311    }
 312
 313    fn reload(
 314        &mut self,
 315        project: ModelHandle<Project>,
 316        cx: &mut ViewContext<Self>,
 317    ) -> Task<anyhow::Result<()>> {
 318        self.results_editor
 319            .update(cx, |editor, cx| editor.reload(project, cx))
 320    }
 321
 322    fn clone_on_split(&self, _workspace_id: WorkspaceId, cx: &mut ViewContext<Self>) -> Option<Self>
 323    where
 324        Self: Sized,
 325    {
 326        let model = self.model.update(cx, |model, cx| model.clone(cx));
 327        Some(Self::new(model, cx))
 328    }
 329
 330    fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut ViewContext<Self>) {
 331        self.results_editor.update(cx, |editor, _| {
 332            editor.set_nav_history(Some(nav_history));
 333        });
 334    }
 335
 336    fn navigate(&mut self, data: Box<dyn Any>, cx: &mut ViewContext<Self>) -> bool {
 337        self.results_editor
 338            .update(cx, |editor, cx| editor.navigate(data, cx))
 339    }
 340
 341    fn git_diff_recalc(
 342        &mut self,
 343        project: ModelHandle<Project>,
 344        cx: &mut ViewContext<Self>,
 345    ) -> Task<anyhow::Result<()>> {
 346        self.results_editor
 347            .update(cx, |editor, cx| editor.git_diff_recalc(project, cx))
 348    }
 349
 350    fn to_item_events(event: &Self::Event) -> SmallVec<[ItemEvent; 2]> {
 351        match event {
 352            ViewEvent::UpdateTab => {
 353                smallvec::smallvec![ItemEvent::UpdateBreadcrumbs, ItemEvent::UpdateTab]
 354            }
 355            ViewEvent::EditorEvent(editor_event) => Editor::to_item_events(editor_event),
 356            _ => SmallVec::new(),
 357        }
 358    }
 359
 360    fn breadcrumb_location(&self) -> ToolbarItemLocation {
 361        if self.has_matches() {
 362            ToolbarItemLocation::Secondary
 363        } else {
 364            ToolbarItemLocation::Hidden
 365        }
 366    }
 367
 368    fn breadcrumbs(&self, theme: &theme::Theme, cx: &AppContext) -> Option<Vec<BreadcrumbText>> {
 369        self.results_editor.breadcrumbs(theme, cx)
 370    }
 371
 372    fn serialized_item_kind() -> Option<&'static str> {
 373        None
 374    }
 375
 376    fn deserialize(
 377        _project: ModelHandle<Project>,
 378        _workspace: WeakViewHandle<Workspace>,
 379        _workspace_id: workspace::WorkspaceId,
 380        _item_id: workspace::ItemId,
 381        _cx: &mut ViewContext<Pane>,
 382    ) -> Task<anyhow::Result<ViewHandle<Self>>> {
 383        unimplemented!()
 384    }
 385}
 386
 387impl ProjectSearchView {
 388    fn new(model: ModelHandle<ProjectSearch>, cx: &mut ViewContext<Self>) -> Self {
 389        let project;
 390        let excerpts;
 391        let mut query_text = String::new();
 392        let mut regex = false;
 393        let mut case_sensitive = false;
 394        let mut whole_word = false;
 395
 396        {
 397            let model = model.read(cx);
 398            project = model.project.clone();
 399            excerpts = model.excerpts.clone();
 400            if let Some(active_query) = model.active_query.as_ref() {
 401                query_text = active_query.as_str().to_string();
 402                regex = active_query.is_regex();
 403                case_sensitive = active_query.case_sensitive();
 404                whole_word = active_query.whole_word();
 405            }
 406        }
 407        cx.observe(&model, |this, _, cx| this.model_changed(cx))
 408            .detach();
 409
 410        let query_editor = cx.add_view(|cx| {
 411            let mut editor = Editor::single_line(
 412                Some(Arc::new(|theme| theme.search.editor.input.clone())),
 413                cx,
 414            );
 415            editor.set_text(query_text, cx);
 416            editor
 417        });
 418        // Subcribe to query_editor in order to reraise editor events for workspace item activation purposes
 419        cx.subscribe(&query_editor, |_, _, event, cx| {
 420            cx.emit(ViewEvent::EditorEvent(event.clone()))
 421        })
 422        .detach();
 423
 424        let results_editor = cx.add_view(|cx| {
 425            let mut editor = Editor::for_multibuffer(excerpts, Some(project), cx);
 426            editor.set_searchable(false);
 427            editor
 428        });
 429        cx.observe(&results_editor, |_, _, cx| cx.emit(ViewEvent::UpdateTab))
 430            .detach();
 431
 432        cx.subscribe(&results_editor, |this, _, event, cx| {
 433            if matches!(event, editor::Event::SelectionsChanged { .. }) {
 434                this.update_match_index(cx);
 435            }
 436            // Reraise editor events for workspace item activation purposes
 437            cx.emit(ViewEvent::EditorEvent(event.clone()));
 438        })
 439        .detach();
 440
 441        let mut this = ProjectSearchView {
 442            search_id: model.read(cx).search_id,
 443            model,
 444            query_editor,
 445            results_editor,
 446            case_sensitive,
 447            whole_word,
 448            regex,
 449            query_contains_error: false,
 450            active_match_index: None,
 451        };
 452        this.model_changed(cx);
 453        this
 454    }
 455
 456    // Re-activate the most recently activated search or the most recent if it has been closed.
 457    // If no search exists in the workspace, create a new one.
 458    fn deploy(
 459        workspace: &mut Workspace,
 460        _: &workspace::NewSearch,
 461        cx: &mut ViewContext<Workspace>,
 462    ) {
 463        // Clean up entries for dropped projects
 464        cx.update_global(|state: &mut ActiveSearches, cx| {
 465            state.0.retain(|project, _| project.is_upgradable(cx))
 466        });
 467
 468        let active_search = cx
 469            .global::<ActiveSearches>()
 470            .0
 471            .get(&workspace.project().downgrade());
 472
 473        let existing = active_search
 474            .and_then(|active_search| {
 475                workspace
 476                    .items_of_type::<ProjectSearchView>(cx)
 477                    .find(|search| search == active_search)
 478            })
 479            .or_else(|| workspace.item_of_type::<ProjectSearchView>(cx));
 480
 481        let query = workspace.active_item(cx).and_then(|item| {
 482            let editor = item.act_as::<Editor>(cx)?;
 483            let query = editor.query_suggestion(cx);
 484            if query.is_empty() {
 485                None
 486            } else {
 487                Some(query)
 488            }
 489        });
 490
 491        let search = if let Some(existing) = existing {
 492            workspace.activate_item(&existing, cx);
 493            existing
 494        } else {
 495            let model = cx.add_model(|cx| ProjectSearch::new(workspace.project().clone(), cx));
 496            let view = cx.add_view(|cx| ProjectSearchView::new(model, cx));
 497            workspace.add_item(Box::new(view.clone()), cx);
 498            view
 499        };
 500
 501        search.update(cx, |search, cx| {
 502            if let Some(query) = query {
 503                search.set_query(&query, cx);
 504            }
 505            search.focus_query_editor(cx)
 506        });
 507    }
 508
 509    fn search(&mut self, cx: &mut ViewContext<Self>) {
 510        if let Some(query) = self.build_search_query(cx) {
 511            self.model.update(cx, |model, cx| model.search(query, cx));
 512        }
 513    }
 514
 515    fn build_search_query(&mut self, cx: &mut ViewContext<Self>) -> Option<SearchQuery> {
 516        let text = self.query_editor.read(cx).text(cx);
 517        if self.regex {
 518            match SearchQuery::regex(text, self.whole_word, self.case_sensitive) {
 519                Ok(query) => Some(query),
 520                Err(_) => {
 521                    self.query_contains_error = true;
 522                    cx.notify();
 523                    None
 524                }
 525            }
 526        } else {
 527            Some(SearchQuery::text(
 528                text,
 529                self.whole_word,
 530                self.case_sensitive,
 531            ))
 532        }
 533    }
 534
 535    fn select_match(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
 536        if let Some(index) = self.active_match_index {
 537            let match_ranges = self.model.read(cx).match_ranges.clone();
 538            let new_index = self.results_editor.update(cx, |editor, cx| {
 539                editor.match_index_for_direction(&match_ranges, index, direction, cx)
 540            });
 541
 542            let range_to_select = match_ranges[new_index].clone();
 543            self.results_editor.update(cx, |editor, cx| {
 544                editor.unfold_ranges([range_to_select.clone()], false, true, cx);
 545                editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
 546                    s.select_ranges([range_to_select])
 547                });
 548            });
 549        }
 550    }
 551
 552    fn focus_query_editor(&self, cx: &mut ViewContext<Self>) {
 553        self.query_editor.update(cx, |query_editor, cx| {
 554            query_editor.select_all(&SelectAll, cx);
 555        });
 556        cx.focus(&self.query_editor);
 557    }
 558
 559    fn set_query(&mut self, query: &str, cx: &mut ViewContext<Self>) {
 560        self.query_editor
 561            .update(cx, |query_editor, cx| query_editor.set_text(query, cx));
 562    }
 563
 564    fn focus_results_editor(&self, cx: &mut ViewContext<Self>) {
 565        self.query_editor.update(cx, |query_editor, cx| {
 566            let cursor = query_editor.selections.newest_anchor().head();
 567            query_editor.change_selections(None, cx, |s| s.select_ranges([cursor.clone()..cursor]));
 568        });
 569        cx.focus(&self.results_editor);
 570    }
 571
 572    fn model_changed(&mut self, cx: &mut ViewContext<Self>) {
 573        let match_ranges = self.model.read(cx).match_ranges.clone();
 574        if match_ranges.is_empty() {
 575            self.active_match_index = None;
 576        } else {
 577            let prev_search_id = mem::replace(&mut self.search_id, self.model.read(cx).search_id);
 578            let is_new_search = self.search_id != prev_search_id;
 579            self.results_editor.update(cx, |editor, cx| {
 580                if is_new_search {
 581                    editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
 582                        s.select_ranges(match_ranges.first().cloned())
 583                    });
 584                }
 585                editor.highlight_background::<Self>(
 586                    match_ranges,
 587                    |theme| theme.search.match_background,
 588                    cx,
 589                );
 590            });
 591            if is_new_search && self.query_editor.is_focused(cx) {
 592                self.focus_results_editor(cx);
 593            }
 594        }
 595
 596        cx.emit(ViewEvent::UpdateTab);
 597        cx.notify();
 598    }
 599
 600    fn update_match_index(&mut self, cx: &mut ViewContext<Self>) {
 601        let results_editor = self.results_editor.read(cx);
 602        let new_index = active_match_index(
 603            &self.model.read(cx).match_ranges,
 604            &results_editor.selections.newest_anchor().head(),
 605            &results_editor.buffer().read(cx).snapshot(cx),
 606        );
 607        if self.active_match_index != new_index {
 608            self.active_match_index = new_index;
 609            cx.notify();
 610        }
 611    }
 612
 613    pub fn has_matches(&self) -> bool {
 614        self.active_match_index.is_some()
 615    }
 616}
 617
 618impl Default for ProjectSearchBar {
 619    fn default() -> Self {
 620        Self::new()
 621    }
 622}
 623
 624impl ProjectSearchBar {
 625    pub fn new() -> Self {
 626        Self {
 627            active_project_search: Default::default(),
 628            subscription: Default::default(),
 629        }
 630    }
 631
 632    fn search(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
 633        if let Some(search_view) = self.active_project_search.as_ref() {
 634            search_view.update(cx, |search_view, cx| search_view.search(cx));
 635        }
 636    }
 637
 638    fn search_in_new(workspace: &mut Workspace, _: &SearchInNew, cx: &mut ViewContext<Workspace>) {
 639        if let Some(search_view) = workspace
 640            .active_item(cx)
 641            .and_then(|item| item.downcast::<ProjectSearchView>())
 642        {
 643            let new_query = search_view.update(cx, |search_view, cx| {
 644                let new_query = search_view.build_search_query(cx);
 645                if new_query.is_some() {
 646                    if let Some(old_query) = search_view.model.read(cx).active_query.clone() {
 647                        search_view.query_editor.update(cx, |editor, cx| {
 648                            editor.set_text(old_query.as_str(), cx);
 649                        });
 650                        search_view.regex = old_query.is_regex();
 651                        search_view.whole_word = old_query.whole_word();
 652                        search_view.case_sensitive = old_query.case_sensitive();
 653                    }
 654                }
 655                new_query
 656            });
 657            if let Some(new_query) = new_query {
 658                let model = cx.add_model(|cx| {
 659                    let mut model = ProjectSearch::new(workspace.project().clone(), cx);
 660                    model.search(new_query, cx);
 661                    model
 662                });
 663                workspace.add_item(
 664                    Box::new(cx.add_view(|cx| ProjectSearchView::new(model, cx))),
 665                    cx,
 666                );
 667            }
 668        }
 669    }
 670
 671    fn select_next_match(pane: &mut Pane, _: &SelectNextMatch, cx: &mut ViewContext<Pane>) {
 672        if let Some(search_view) = pane
 673            .active_item()
 674            .and_then(|item| item.downcast::<ProjectSearchView>())
 675        {
 676            search_view.update(cx, |view, cx| view.select_match(Direction::Next, cx));
 677        } else {
 678            cx.propagate_action();
 679        }
 680    }
 681
 682    fn select_prev_match(pane: &mut Pane, _: &SelectPrevMatch, cx: &mut ViewContext<Pane>) {
 683        if let Some(search_view) = pane
 684            .active_item()
 685            .and_then(|item| item.downcast::<ProjectSearchView>())
 686        {
 687            search_view.update(cx, |view, cx| view.select_match(Direction::Prev, cx));
 688        } else {
 689            cx.propagate_action();
 690        }
 691    }
 692
 693    fn toggle_focus(pane: &mut Pane, _: &ToggleFocus, cx: &mut ViewContext<Pane>) {
 694        if let Some(search_view) = pane
 695            .active_item()
 696            .and_then(|item| item.downcast::<ProjectSearchView>())
 697        {
 698            search_view.update(cx, |search_view, cx| {
 699                if search_view.query_editor.is_focused(cx) {
 700                    if !search_view.model.read(cx).match_ranges.is_empty() {
 701                        search_view.focus_results_editor(cx);
 702                    }
 703                } else {
 704                    search_view.focus_query_editor(cx);
 705                }
 706            });
 707        } else {
 708            cx.propagate_action();
 709        }
 710    }
 711
 712    fn tab(&mut self, _: &editor::Tab, cx: &mut ViewContext<Self>) {
 713        if let Some(search_view) = self.active_project_search.as_ref() {
 714            search_view.update(cx, |search_view, cx| {
 715                if search_view.query_editor.is_focused(cx) {
 716                    if !search_view.model.read(cx).match_ranges.is_empty() {
 717                        search_view.focus_results_editor(cx);
 718                    }
 719                } else {
 720                    cx.propagate_action();
 721                }
 722            });
 723        } else {
 724            cx.propagate_action();
 725        }
 726    }
 727
 728    fn toggle_search_option(&mut self, option: SearchOption, cx: &mut ViewContext<Self>) -> bool {
 729        if let Some(search_view) = self.active_project_search.as_ref() {
 730            search_view.update(cx, |search_view, cx| {
 731                let value = match option {
 732                    SearchOption::WholeWord => &mut search_view.whole_word,
 733                    SearchOption::CaseSensitive => &mut search_view.case_sensitive,
 734                    SearchOption::Regex => &mut search_view.regex,
 735                };
 736                *value = !*value;
 737                search_view.search(cx);
 738            });
 739            cx.notify();
 740            true
 741        } else {
 742            false
 743        }
 744    }
 745
 746    fn render_nav_button(
 747        &self,
 748        icon: &'static str,
 749        direction: Direction,
 750        cx: &mut ViewContext<Self>,
 751    ) -> AnyElement<Self> {
 752        let action: Box<dyn Action>;
 753        let tooltip;
 754        match direction {
 755            Direction::Prev => {
 756                action = Box::new(SelectPrevMatch);
 757                tooltip = "Select Previous Match";
 758            }
 759            Direction::Next => {
 760                action = Box::new(SelectNextMatch);
 761                tooltip = "Select Next Match";
 762            }
 763        };
 764        let tooltip_style = cx.global::<Settings>().theme.tooltip.clone();
 765
 766        enum NavButton {}
 767        MouseEventHandler::<NavButton, _>::new(direction as usize, cx, |state, cx| {
 768            let style = &cx
 769                .global::<Settings>()
 770                .theme
 771                .search
 772                .option_button
 773                .style_for(state, false);
 774            Label::new(icon, style.text.clone())
 775                .contained()
 776                .with_style(style.container)
 777        })
 778        .on_click(MouseButton::Left, {
 779            let action = action.boxed_clone();
 780            move |_, _, cx| cx.dispatch_any_action(action.boxed_clone())
 781        })
 782        .with_cursor_style(CursorStyle::PointingHand)
 783        .with_tooltip::<NavButton>(
 784            direction as usize,
 785            tooltip.to_string(),
 786            Some(action),
 787            tooltip_style,
 788            cx,
 789        )
 790        .into_any()
 791    }
 792
 793    fn render_option_button(
 794        &self,
 795        icon: &'static str,
 796        option: SearchOption,
 797        cx: &mut ViewContext<Self>,
 798    ) -> AnyElement<Self> {
 799        let tooltip_style = cx.global::<Settings>().theme.tooltip.clone();
 800        let is_active = self.is_option_enabled(option, cx);
 801        MouseEventHandler::<Self, _>::new(option as usize, cx, |state, cx| {
 802            let style = &cx
 803                .global::<Settings>()
 804                .theme
 805                .search
 806                .option_button
 807                .style_for(state, is_active);
 808            Label::new(icon, style.text.clone())
 809                .contained()
 810                .with_style(style.container)
 811        })
 812        .on_click(MouseButton::Left, move |_, _, cx| {
 813            cx.dispatch_any_action(option.to_toggle_action())
 814        })
 815        .with_cursor_style(CursorStyle::PointingHand)
 816        .with_tooltip::<Self>(
 817            option as usize,
 818            format!("Toggle {}", option.label()),
 819            Some(option.to_toggle_action()),
 820            tooltip_style,
 821            cx,
 822        )
 823        .into_any()
 824    }
 825
 826    fn is_option_enabled(&self, option: SearchOption, cx: &AppContext) -> bool {
 827        if let Some(search) = self.active_project_search.as_ref() {
 828            let search = search.read(cx);
 829            match option {
 830                SearchOption::WholeWord => search.whole_word,
 831                SearchOption::CaseSensitive => search.case_sensitive,
 832                SearchOption::Regex => search.regex,
 833            }
 834        } else {
 835            false
 836        }
 837    }
 838}
 839
 840impl Entity for ProjectSearchBar {
 841    type Event = ();
 842}
 843
 844impl View for ProjectSearchBar {
 845    fn ui_name() -> &'static str {
 846        "ProjectSearchBar"
 847    }
 848
 849    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
 850        if let Some(search) = self.active_project_search.as_ref() {
 851            let search = search.read(cx);
 852            let theme = cx.global::<Settings>().theme.clone();
 853            let editor_container = if search.query_contains_error {
 854                theme.search.invalid_editor
 855            } else {
 856                theme.search.editor.input.container
 857            };
 858            Flex::row()
 859                .with_child(
 860                    Flex::row()
 861                        .with_child(
 862                            ChildView::new(&search.query_editor, cx)
 863                                .aligned()
 864                                .left()
 865                                .flex(1., true),
 866                        )
 867                        .with_children(search.active_match_index.map(|match_ix| {
 868                            Label::new(
 869                                format!(
 870                                    "{}/{}",
 871                                    match_ix + 1,
 872                                    search.model.read(cx).match_ranges.len()
 873                                ),
 874                                theme.search.match_index.text.clone(),
 875                            )
 876                            .contained()
 877                            .with_style(theme.search.match_index.container)
 878                            .aligned()
 879                        }))
 880                        .contained()
 881                        .with_style(editor_container)
 882                        .aligned()
 883                        .constrained()
 884                        .with_min_width(theme.search.editor.min_width)
 885                        .with_max_width(theme.search.editor.max_width)
 886                        .flex(1., false),
 887                )
 888                .with_child(
 889                    Flex::row()
 890                        .with_child(self.render_nav_button("<", Direction::Prev, cx))
 891                        .with_child(self.render_nav_button(">", Direction::Next, cx))
 892                        .aligned(),
 893                )
 894                .with_child(
 895                    Flex::row()
 896                        .with_child(self.render_option_button(
 897                            "Case",
 898                            SearchOption::CaseSensitive,
 899                            cx,
 900                        ))
 901                        .with_child(self.render_option_button("Word", SearchOption::WholeWord, cx))
 902                        .with_child(self.render_option_button("Regex", SearchOption::Regex, cx))
 903                        .contained()
 904                        .with_style(theme.search.option_button_group)
 905                        .aligned(),
 906                )
 907                .contained()
 908                .with_style(theme.search.container)
 909                .aligned()
 910                .left()
 911                .into_any_named("project search")
 912        } else {
 913            Empty::new().into_any()
 914        }
 915    }
 916}
 917
 918impl ToolbarItemView for ProjectSearchBar {
 919    fn set_active_pane_item(
 920        &mut self,
 921        active_pane_item: Option<&dyn ItemHandle>,
 922        cx: &mut ViewContext<Self>,
 923    ) -> ToolbarItemLocation {
 924        cx.notify();
 925        self.subscription = None;
 926        self.active_project_search = None;
 927        if let Some(search) = active_pane_item.and_then(|i| i.downcast::<ProjectSearchView>()) {
 928            let query_editor = search.read(cx).query_editor.clone();
 929            cx.reparent(&query_editor);
 930            self.subscription = Some(cx.observe(&search, |_, _, cx| cx.notify()));
 931            self.active_project_search = Some(search);
 932            ToolbarItemLocation::PrimaryLeft {
 933                flex: Some((1., false)),
 934            }
 935        } else {
 936            ToolbarItemLocation::Hidden
 937        }
 938    }
 939}
 940
 941#[cfg(test)]
 942mod tests {
 943    use super::*;
 944    use editor::DisplayPoint;
 945    use gpui::{color::Color, executor::Deterministic, TestAppContext};
 946    use project::FakeFs;
 947    use serde_json::json;
 948    use std::sync::Arc;
 949
 950    #[gpui::test]
 951    async fn test_project_search(deterministic: Arc<Deterministic>, cx: &mut TestAppContext) {
 952        let fonts = cx.font_cache();
 953        let mut theme = gpui::fonts::with_font_cache(fonts.clone(), theme::Theme::default);
 954        theme.search.match_background = Color::red();
 955        cx.update(|cx| {
 956            let mut settings = Settings::test(cx);
 957            settings.theme = Arc::new(theme);
 958            cx.set_global(settings);
 959            cx.set_global(ActiveSearches::default());
 960        });
 961
 962        let fs = FakeFs::new(cx.background());
 963        fs.insert_tree(
 964            "/dir",
 965            json!({
 966                "one.rs": "const ONE: usize = 1;",
 967                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
 968                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
 969                "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
 970            }),
 971        )
 972        .await;
 973        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
 974        let search = cx.add_model(|cx| ProjectSearch::new(project, cx));
 975        let (_, search_view) = cx.add_window(|cx| ProjectSearchView::new(search.clone(), cx));
 976
 977        search_view.update(cx, |search_view, cx| {
 978            search_view
 979                .query_editor
 980                .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
 981            search_view.search(cx);
 982        });
 983        deterministic.run_until_parked();
 984        search_view.update(cx, |search_view, cx| {
 985            assert_eq!(
 986                search_view
 987                    .results_editor
 988                    .update(cx, |editor, cx| editor.display_text(cx)),
 989                "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;"
 990            );
 991            assert_eq!(
 992                search_view
 993                    .results_editor
 994                    .update(cx, |editor, cx| editor.all_background_highlights(cx)),
 995                &[
 996                    (
 997                        DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35),
 998                        Color::red()
 999                    ),
1000                    (
1001                        DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40),
1002                        Color::red()
1003                    ),
1004                    (
1005                        DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9),
1006                        Color::red()
1007                    )
1008                ]
1009            );
1010            assert_eq!(search_view.active_match_index, Some(0));
1011            assert_eq!(
1012                search_view
1013                    .results_editor
1014                    .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1015                [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
1016            );
1017
1018            search_view.select_match(Direction::Next, cx);
1019        });
1020
1021        search_view.update(cx, |search_view, cx| {
1022            assert_eq!(search_view.active_match_index, Some(1));
1023            assert_eq!(
1024                search_view
1025                    .results_editor
1026                    .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1027                [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
1028            );
1029            search_view.select_match(Direction::Next, cx);
1030        });
1031
1032        search_view.update(cx, |search_view, cx| {
1033            assert_eq!(search_view.active_match_index, Some(2));
1034            assert_eq!(
1035                search_view
1036                    .results_editor
1037                    .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1038                [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
1039            );
1040            search_view.select_match(Direction::Next, cx);
1041        });
1042
1043        search_view.update(cx, |search_view, cx| {
1044            assert_eq!(search_view.active_match_index, Some(0));
1045            assert_eq!(
1046                search_view
1047                    .results_editor
1048                    .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1049                [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
1050            );
1051            search_view.select_match(Direction::Prev, cx);
1052        });
1053
1054        search_view.update(cx, |search_view, cx| {
1055            assert_eq!(search_view.active_match_index, Some(2));
1056            assert_eq!(
1057                search_view
1058                    .results_editor
1059                    .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1060                [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
1061            );
1062            search_view.select_match(Direction::Prev, cx);
1063        });
1064
1065        search_view.update(cx, |search_view, cx| {
1066            assert_eq!(search_view.active_match_index, Some(1));
1067            assert_eq!(
1068                search_view
1069                    .results_editor
1070                    .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1071                [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
1072            );
1073        });
1074    }
1075}