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