project_search.rs

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