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