project_search.rs

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