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