project_find.rs

  1use crate::SearchOption;
  2use editor::{Anchor, Autoscroll, Editor, MultiBuffer};
  3use gpui::{
  4    action, elements::*, keymap::Binding, platform::CursorStyle, AppContext, ElementBox, Entity,
  5    ModelContext, ModelHandle, MutableAppContext, RenderContext, Task, View, ViewContext,
  6    ViewHandle,
  7};
  8use postage::watch;
  9use project::{search::SearchQuery, Project};
 10use std::{
 11    any::{Any, TypeId},
 12    ops::Range,
 13    path::PathBuf,
 14};
 15use util::ResultExt as _;
 16use workspace::{Item, ItemHandle, ItemNavHistory, ItemView, Settings, Workspace};
 17
 18action!(Deploy, bool);
 19action!(Search);
 20action!(ToggleSearchOption, SearchOption);
 21action!(ToggleFocus);
 22
 23pub fn init(cx: &mut MutableAppContext) {
 24    cx.add_bindings([
 25        Binding::new("cmd-shift-F", ToggleFocus, Some("ProjectFindView")),
 26        Binding::new("cmd-shift-F", ToggleFocus, Some("ProjectFindView")),
 27        Binding::new("cmd-f", ToggleFocus, Some("ProjectFindView")),
 28        Binding::new("cmd-shift-F", Deploy(true), Some("Workspace")),
 29        Binding::new("cmd-alt-shift-F", Deploy(false), Some("Workspace")),
 30        Binding::new("enter", Search, Some("ProjectFindView")),
 31    ]);
 32    cx.add_action(ProjectFindView::deploy);
 33    cx.add_action(ProjectFindView::search);
 34    cx.add_action(ProjectFindView::toggle_search_option);
 35    cx.add_action(ProjectFindView::toggle_focus);
 36}
 37
 38struct ProjectFind {
 39    project: ModelHandle<Project>,
 40    excerpts: ModelHandle<MultiBuffer>,
 41    pending_search: Option<Task<Option<()>>>,
 42    highlighted_ranges: Vec<Range<Anchor>>,
 43}
 44
 45struct ProjectFindView {
 46    model: ModelHandle<ProjectFind>,
 47    query_editor: ViewHandle<Editor>,
 48    results_editor: ViewHandle<Editor>,
 49    case_sensitive: bool,
 50    whole_word: bool,
 51    regex: bool,
 52    query_contains_error: bool,
 53    settings: watch::Receiver<Settings>,
 54}
 55
 56impl Entity for ProjectFind {
 57    type Event = ();
 58}
 59
 60impl ProjectFind {
 61    fn new(project: ModelHandle<Project>, cx: &mut ModelContext<Self>) -> Self {
 62        let replica_id = project.read(cx).replica_id();
 63        Self {
 64            project,
 65            excerpts: cx.add_model(|_| MultiBuffer::new(replica_id)),
 66            pending_search: Default::default(),
 67            highlighted_ranges: Default::default(),
 68        }
 69    }
 70
 71    fn clone(&self, new_cx: &mut ModelContext<Self>) -> Self {
 72        Self {
 73            project: self.project.clone(),
 74            excerpts: self
 75                .excerpts
 76                .update(new_cx, |excerpts, cx| cx.add_model(|cx| excerpts.clone(cx))),
 77            pending_search: Default::default(),
 78            highlighted_ranges: self.highlighted_ranges.clone(),
 79        }
 80    }
 81
 82    fn search(&mut self, query: SearchQuery, cx: &mut ModelContext<Self>) {
 83        let search = self
 84            .project
 85            .update(cx, |project, cx| project.search(query.clone(), cx));
 86        self.highlighted_ranges.clear();
 87        self.pending_search = Some(cx.spawn_weak(|this, mut cx| async move {
 88            let matches = search.await.log_err()?;
 89            if let Some(this) = this.upgrade(&cx) {
 90                this.update(&mut cx, |this, cx| {
 91                    this.highlighted_ranges.clear();
 92                    let mut matches = matches.into_iter().collect::<Vec<_>>();
 93                    matches
 94                        .sort_by_key(|(buffer, _)| buffer.read(cx).file().map(|file| file.path()));
 95                    this.excerpts.update(cx, |excerpts, cx| {
 96                        excerpts.clear(cx);
 97                        for (buffer, buffer_matches) in matches {
 98                            let ranges_to_highlight = excerpts.push_excerpts_with_context_lines(
 99                                buffer,
100                                buffer_matches.clone(),
101                                1,
102                                cx,
103                            );
104                            this.highlighted_ranges.extend(ranges_to_highlight);
105                        }
106                    });
107                    this.pending_search.take();
108                    cx.notify();
109                });
110            }
111            None
112        }));
113        cx.notify();
114    }
115}
116
117impl Item for ProjectFind {
118    type View = ProjectFindView;
119
120    fn build_view(
121        model: ModelHandle<Self>,
122        workspace: &Workspace,
123        nav_history: ItemNavHistory,
124        cx: &mut gpui::ViewContext<Self::View>,
125    ) -> Self::View {
126        let settings = workspace.settings();
127        let excerpts = model.read(cx).excerpts.clone();
128        cx.observe(&model, |this, _, cx| this.model_changed(true, cx))
129            .detach();
130        ProjectFindView {
131            model,
132            query_editor: cx.add_view(|cx| {
133                Editor::single_line(
134                    settings.clone(),
135                    Some(|theme| theme.find.editor.input.clone()),
136                    cx,
137                )
138            }),
139            results_editor: cx.add_view(|cx| {
140                let mut editor = Editor::for_buffer(
141                    excerpts,
142                    Some(workspace.project().clone()),
143                    settings.clone(),
144                    cx,
145                );
146                editor.set_searchable(false);
147                editor.set_nav_history(Some(nav_history));
148                editor
149            }),
150            case_sensitive: false,
151            whole_word: false,
152            regex: false,
153            query_contains_error: false,
154            settings,
155        }
156    }
157
158    fn project_path(&self) -> Option<project::ProjectPath> {
159        None
160    }
161}
162
163impl Entity for ProjectFindView {
164    type Event = ();
165}
166
167impl View for ProjectFindView {
168    fn ui_name() -> &'static str {
169        "ProjectFindView"
170    }
171
172    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
173        let model = &self.model.read(cx);
174        let results = if model.highlighted_ranges.is_empty() {
175            let theme = &self.settings.borrow().theme;
176            let text = if self.query_editor.read(cx).text(cx).is_empty() {
177                ""
178            } else if model.pending_search.is_some() {
179                "Searching..."
180            } else {
181                "No results"
182            };
183            Label::new(text.to_string(), theme.find.results_status.clone())
184                .aligned()
185                .contained()
186                .with_background_color(theme.editor.background)
187                .flexible(1., true)
188                .boxed()
189        } else {
190            ChildView::new(&self.results_editor)
191                .flexible(1., true)
192                .boxed()
193        };
194
195        Flex::column()
196            .with_child(self.render_query_editor(cx))
197            .with_child(results)
198            .boxed()
199    }
200
201    fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
202        if self.model.read(cx).highlighted_ranges.is_empty() {
203            cx.focus(&self.query_editor);
204        } else {
205            cx.focus(&self.results_editor);
206        }
207    }
208}
209
210impl ItemView for ProjectFindView {
211    fn act_as_type(
212        &self,
213        type_id: TypeId,
214        self_handle: &ViewHandle<Self>,
215        _: &gpui::AppContext,
216    ) -> Option<gpui::AnyViewHandle> {
217        if type_id == TypeId::of::<Self>() {
218            Some(self_handle.into())
219        } else if type_id == TypeId::of::<Editor>() {
220            Some((&self.results_editor).into())
221        } else {
222            None
223        }
224    }
225
226    fn deactivated(&mut self, cx: &mut ViewContext<Self>) {
227        self.results_editor
228            .update(cx, |editor, cx| editor.deactivated(cx));
229    }
230
231    fn item(&self, _: &gpui::AppContext) -> Box<dyn ItemHandle> {
232        Box::new(self.model.clone())
233    }
234
235    fn tab_content(&self, style: &theme::Tab, _: &gpui::AppContext) -> ElementBox {
236        Label::new("Project Find".to_string(), style.label.clone()).boxed()
237    }
238
239    fn project_path(&self, _: &gpui::AppContext) -> Option<project::ProjectPath> {
240        None
241    }
242
243    fn can_save(&self, _: &gpui::AppContext) -> bool {
244        true
245    }
246
247    fn is_dirty(&self, cx: &AppContext) -> bool {
248        self.results_editor.read(cx).is_dirty(cx)
249    }
250
251    fn has_conflict(&self, cx: &AppContext) -> bool {
252        self.results_editor.read(cx).has_conflict(cx)
253    }
254
255    fn save(
256        &mut self,
257        project: ModelHandle<Project>,
258        cx: &mut ViewContext<Self>,
259    ) -> Task<anyhow::Result<()>> {
260        self.results_editor
261            .update(cx, |editor, cx| editor.save(project, cx))
262    }
263
264    fn can_save_as(&self, _: &gpui::AppContext) -> bool {
265        false
266    }
267
268    fn save_as(
269        &mut self,
270        _: ModelHandle<Project>,
271        _: PathBuf,
272        _: &mut ViewContext<Self>,
273    ) -> Task<anyhow::Result<()>> {
274        unreachable!("save_as should not have been called")
275    }
276
277    fn clone_on_split(
278        &self,
279        nav_history: ItemNavHistory,
280        cx: &mut ViewContext<Self>,
281    ) -> Option<Self>
282    where
283        Self: Sized,
284    {
285        let query_editor = cx.add_view(|cx| {
286            let query = self.query_editor.read(cx).text(cx);
287            let editor = Editor::single_line(
288                self.settings.clone(),
289                Some(|theme| theme.find.editor.input.clone()),
290                cx,
291            );
292            editor
293                .buffer()
294                .update(cx, |buffer, cx| buffer.edit([0..0], query, cx));
295            editor
296        });
297        let model = self
298            .model
299            .update(cx, |model, cx| cx.add_model(|cx| model.clone(cx)));
300
301        cx.observe(&model, |this, _, cx| this.model_changed(true, cx))
302            .detach();
303        let results_editor = cx.add_view(|cx| {
304            let model = model.read(cx);
305            let excerpts = model.excerpts.clone();
306            let project = model.project.clone();
307            let scroll_position = self
308                .results_editor
309                .update(cx, |editor, cx| editor.scroll_position(cx));
310
311            let mut editor = Editor::for_buffer(excerpts, Some(project), self.settings.clone(), cx);
312            editor.set_searchable(false);
313            editor.set_nav_history(Some(nav_history));
314            editor.set_scroll_position(scroll_position, cx);
315            editor
316        });
317        let mut view = Self {
318            model,
319            query_editor,
320            results_editor,
321            case_sensitive: self.case_sensitive,
322            whole_word: self.whole_word,
323            regex: self.regex,
324            query_contains_error: self.query_contains_error,
325            settings: self.settings.clone(),
326        };
327        view.model_changed(false, cx);
328        Some(view)
329    }
330
331    fn navigate(&mut self, data: Box<dyn Any>, cx: &mut ViewContext<Self>) {
332        self.results_editor
333            .update(cx, |editor, cx| editor.navigate(data, cx));
334    }
335}
336
337impl ProjectFindView {
338    fn deploy(
339        workspace: &mut Workspace,
340        &Deploy(activate_existing): &Deploy,
341        cx: &mut ViewContext<Workspace>,
342    ) {
343        if activate_existing {
344            if let Some(existing) = workspace.item_of_type::<ProjectFind>(cx) {
345                workspace.activate_item(&existing, cx);
346                return;
347            }
348        }
349        let model = cx.add_model(|cx| ProjectFind::new(workspace.project().clone(), cx));
350        workspace.open_item(model, cx);
351    }
352
353    fn search(&mut self, _: &Search, cx: &mut ViewContext<Self>) {
354        let text = self.query_editor.read(cx).text(cx);
355        let query = if self.regex {
356            match SearchQuery::regex(text, self.whole_word, self.case_sensitive) {
357                Ok(query) => query,
358                Err(_) => {
359                    self.query_contains_error = true;
360                    cx.notify();
361                    return;
362                }
363            }
364        } else {
365            SearchQuery::text(text, self.whole_word, self.case_sensitive)
366        };
367
368        self.model.update(cx, |model, cx| model.search(query, cx));
369    }
370
371    fn toggle_search_option(
372        &mut self,
373        ToggleSearchOption(option): &ToggleSearchOption,
374        cx: &mut ViewContext<Self>,
375    ) {
376        let value = match option {
377            SearchOption::WholeWord => &mut self.whole_word,
378            SearchOption::CaseSensitive => &mut self.case_sensitive,
379            SearchOption::Regex => &mut self.regex,
380        };
381        *value = !*value;
382        self.search(&Search, cx);
383        cx.notify();
384    }
385
386    fn toggle_focus(&mut self, _: &ToggleFocus, cx: &mut ViewContext<Self>) {
387        if self.query_editor.is_focused(cx) {
388            cx.focus(&self.results_editor);
389        } else {
390            cx.focus(&self.query_editor);
391        }
392    }
393
394    fn model_changed(&mut self, reset_selections: bool, cx: &mut ViewContext<Self>) {
395        let highlighted_ranges = self.model.read(cx).highlighted_ranges.clone();
396        if !highlighted_ranges.is_empty() {
397            let theme = &self.settings.borrow().theme.find;
398            self.results_editor.update(cx, |editor, cx| {
399                editor.highlight_ranges::<Self>(highlighted_ranges, theme.match_background, cx);
400                if reset_selections {
401                    editor.select_ranges([0..0], Some(Autoscroll::Fit), cx);
402                }
403            });
404            if self.query_editor.is_focused(cx) {
405                cx.focus(&self.results_editor);
406            }
407        }
408
409        cx.notify();
410    }
411
412    fn render_query_editor(&self, cx: &mut RenderContext<Self>) -> ElementBox {
413        let theme = &self.settings.borrow().theme;
414        let editor_container = if self.query_contains_error {
415            theme.find.invalid_editor
416        } else {
417            theme.find.editor.input.container
418        };
419        Flex::row()
420            .with_child(
421                ChildView::new(&self.query_editor)
422                    .contained()
423                    .with_style(editor_container)
424                    .aligned()
425                    .constrained()
426                    .with_max_width(theme.find.editor.max_width)
427                    .boxed(),
428            )
429            .with_child(
430                Flex::row()
431                    .with_child(self.render_option_button("Case", SearchOption::CaseSensitive, cx))
432                    .with_child(self.render_option_button("Word", SearchOption::WholeWord, cx))
433                    .with_child(self.render_option_button("Regex", SearchOption::Regex, cx))
434                    .contained()
435                    .with_style(theme.find.option_button_group)
436                    .aligned()
437                    .boxed(),
438            )
439            .contained()
440            .with_style(theme.find.container)
441            .constrained()
442            .with_height(theme.workspace.toolbar.height)
443            .named("find bar")
444    }
445
446    fn render_option_button(
447        &self,
448        icon: &str,
449        option: SearchOption,
450        cx: &mut RenderContext<Self>,
451    ) -> ElementBox {
452        let theme = &self.settings.borrow().theme.find;
453        let is_active = self.is_option_enabled(option);
454        MouseEventHandler::new::<Self, _, _>(option as usize, cx, |state, _| {
455            let style = match (is_active, state.hovered) {
456                (false, false) => &theme.option_button,
457                (false, true) => &theme.hovered_option_button,
458                (true, false) => &theme.active_option_button,
459                (true, true) => &theme.active_hovered_option_button,
460            };
461            Label::new(icon.to_string(), style.text.clone())
462                .contained()
463                .with_style(style.container)
464                .boxed()
465        })
466        .on_click(move |cx| cx.dispatch_action(ToggleSearchOption(option)))
467        .with_cursor_style(CursorStyle::PointingHand)
468        .boxed()
469    }
470
471    fn is_option_enabled(&self, option: SearchOption) -> bool {
472        match option {
473            SearchOption::WholeWord => self.whole_word,
474            SearchOption::CaseSensitive => self.case_sensitive,
475            SearchOption::Regex => self.regex,
476        }
477    }
478}