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}