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