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