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}