1use fuzzy::PathMatch;
2use gpui::{
3 actions, elements::*, AppContext, ModelHandle, MouseState, Task, ViewContext, WeakViewHandle,
4};
5use picker::{Picker, PickerDelegate};
6use project::{PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
7use settings::Settings;
8use std::{
9 path::Path,
10 sync::{
11 atomic::{self, AtomicBool},
12 Arc,
13 },
14};
15use util::{post_inc, ResultExt};
16use workspace::Workspace;
17
18pub type FileFinder = Picker<FileFinderDelegate>;
19
20pub struct FileFinderDelegate {
21 workspace: WeakViewHandle<Workspace>,
22 project: ModelHandle<Project>,
23 search_count: usize,
24 latest_search_id: usize,
25 latest_search_did_cancel: bool,
26 latest_search_query: String,
27 relative_to: Option<Arc<Path>>,
28 matches: Vec<PathMatch>,
29 selected: Option<(usize, Arc<Path>)>,
30 cancel_flag: Arc<AtomicBool>,
31}
32
33actions!(file_finder, [Toggle]);
34
35pub fn init(cx: &mut AppContext) {
36 cx.add_action(toggle_file_finder);
37 FileFinder::init(cx);
38}
39
40fn toggle_file_finder(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
41 workspace.toggle_modal(cx, |workspace, cx| {
42 let relative_to = workspace
43 .active_item(cx)
44 .and_then(|item| item.project_path(cx))
45 .map(|project_path| project_path.path.clone());
46 let project = workspace.project().clone();
47 let workspace = cx.handle().downgrade();
48 let finder = cx.add_view(|cx| {
49 Picker::new(
50 FileFinderDelegate::new(workspace, project, relative_to, cx),
51 cx,
52 )
53 });
54 finder
55 });
56}
57
58pub enum Event {
59 Selected(ProjectPath),
60 Dismissed,
61}
62
63impl FileFinderDelegate {
64 fn labels_for_match(&self, path_match: &PathMatch) -> (String, Vec<usize>, String, Vec<usize>) {
65 let path = &path_match.path;
66 let path_string = path.to_string_lossy();
67 let full_path = [path_match.path_prefix.as_ref(), path_string.as_ref()].join("");
68 let path_positions = path_match.positions.clone();
69
70 let file_name = path.file_name().map_or_else(
71 || path_match.path_prefix.to_string(),
72 |file_name| file_name.to_string_lossy().to_string(),
73 );
74 let file_name_start = path_match.path_prefix.chars().count() + path_string.chars().count()
75 - file_name.chars().count();
76 let file_name_positions = path_positions
77 .iter()
78 .filter_map(|pos| {
79 if pos >= &file_name_start {
80 Some(pos - file_name_start)
81 } else {
82 None
83 }
84 })
85 .collect();
86
87 (file_name, file_name_positions, full_path, path_positions)
88 }
89
90 pub fn new(
91 workspace: WeakViewHandle<Workspace>,
92 project: ModelHandle<Project>,
93 relative_to: Option<Arc<Path>>,
94 cx: &mut ViewContext<FileFinder>,
95 ) -> Self {
96 cx.observe(&project, |picker, _, cx| {
97 picker.update_matches(picker.query(cx), cx);
98 })
99 .detach();
100 Self {
101 workspace,
102 project,
103 search_count: 0,
104 latest_search_id: 0,
105 latest_search_did_cancel: false,
106 latest_search_query: String::new(),
107 relative_to,
108 matches: Vec::new(),
109 selected: None,
110 cancel_flag: Arc::new(AtomicBool::new(false)),
111 }
112 }
113
114 fn spawn_search(&mut self, query: String, cx: &mut ViewContext<FileFinder>) -> Task<()> {
115 let relative_to = self.relative_to.clone();
116 let worktrees = self
117 .project
118 .read(cx)
119 .visible_worktrees(cx)
120 .collect::<Vec<_>>();
121 let include_root_name = worktrees.len() > 1;
122 let candidate_sets = worktrees
123 .into_iter()
124 .map(|worktree| {
125 let worktree = worktree.read(cx);
126 PathMatchCandidateSet {
127 snapshot: worktree.snapshot(),
128 include_ignored: worktree
129 .root_entry()
130 .map_or(false, |entry| entry.is_ignored),
131 include_root_name,
132 }
133 })
134 .collect::<Vec<_>>();
135
136 let search_id = util::post_inc(&mut self.search_count);
137 self.cancel_flag.store(true, atomic::Ordering::Relaxed);
138 self.cancel_flag = Arc::new(AtomicBool::new(false));
139 let cancel_flag = self.cancel_flag.clone();
140 cx.spawn(|picker, mut cx| async move {
141 let matches = fuzzy::match_path_sets(
142 candidate_sets.as_slice(),
143 &query,
144 relative_to,
145 false,
146 100,
147 &cancel_flag,
148 cx.background(),
149 )
150 .await;
151 let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed);
152 picker
153 .update(&mut cx, |picker, cx| {
154 picker
155 .delegate_mut()
156 .set_matches(search_id, did_cancel, query, matches, cx)
157 })
158 .log_err();
159 })
160 }
161
162 fn set_matches(
163 &mut self,
164 search_id: usize,
165 did_cancel: bool,
166 query: String,
167 matches: Vec<PathMatch>,
168 cx: &mut ViewContext<FileFinder>,
169 ) {
170 if search_id >= self.latest_search_id {
171 self.latest_search_id = search_id;
172 if self.latest_search_did_cancel && query == self.latest_search_query {
173 util::extend_sorted(&mut self.matches, matches.into_iter(), 100, |a, b| b.cmp(a));
174 } else {
175 self.matches = matches;
176 }
177 self.latest_search_query = query;
178 self.latest_search_did_cancel = did_cancel;
179 cx.notify();
180 }
181 }
182}
183
184impl PickerDelegate for FileFinderDelegate {
185 fn placeholder_text(&self) -> Arc<str> {
186 "Search project files...".into()
187 }
188
189 fn match_count(&self) -> usize {
190 self.matches.len()
191 }
192
193 fn selected_index(&self) -> usize {
194 if let Some(selected) = self.selected.as_ref() {
195 for (ix, path_match) in self.matches.iter().enumerate() {
196 if (path_match.worktree_id, path_match.path.as_ref())
197 == (selected.0, selected.1.as_ref())
198 {
199 return ix;
200 }
201 }
202 }
203 0
204 }
205
206 fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<FileFinder>) {
207 let mat = &self.matches[ix];
208 self.selected = Some((mat.worktree_id, mat.path.clone()));
209 cx.notify();
210 }
211
212 fn update_matches(&mut self, query: String, cx: &mut ViewContext<FileFinder>) -> Task<()> {
213 if query.is_empty() {
214 self.latest_search_id = post_inc(&mut self.search_count);
215 self.matches.clear();
216 cx.notify();
217 Task::ready(())
218 } else {
219 self.spawn_search(query, cx)
220 }
221 }
222
223 fn confirm(&mut self, cx: &mut ViewContext<FileFinder>) {
224 if let Some(m) = self.matches.get(self.selected_index()) {
225 if let Some(workspace) = self.workspace.upgrade(cx) {
226 let project_path = ProjectPath {
227 worktree_id: WorktreeId::from_usize(m.worktree_id),
228 path: m.path.clone(),
229 };
230
231 workspace.update(cx, |workspace, cx| {
232 workspace
233 .open_path(project_path.clone(), None, true, cx)
234 .detach_and_log_err(cx);
235 workspace.dismiss_modal(cx);
236 })
237 }
238 }
239 }
240
241 fn dismissed(&mut self, _: &mut ViewContext<FileFinder>) {}
242
243 fn render_match(
244 &self,
245 ix: usize,
246 mouse_state: &mut MouseState,
247 selected: bool,
248 cx: &AppContext,
249 ) -> AnyElement<Picker<Self>> {
250 let path_match = &self.matches[ix];
251 let settings = cx.global::<Settings>();
252 let style = settings.theme.picker.item.style_for(mouse_state, selected);
253 let (file_name, file_name_positions, full_path, full_path_positions) =
254 self.labels_for_match(path_match);
255 Flex::column()
256 .with_child(
257 Label::new(file_name, style.label.clone()).with_highlights(file_name_positions),
258 )
259 .with_child(
260 Label::new(full_path, style.label.clone()).with_highlights(full_path_positions),
261 )
262 .flex(1., false)
263 .contained()
264 .with_style(style.container)
265 .into_any_named("match")
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272 use editor::Editor;
273 use menu::{Confirm, SelectNext};
274 use serde_json::json;
275 use workspace::{AppState, Workspace};
276
277 #[ctor::ctor]
278 fn init_logger() {
279 if std::env::var("RUST_LOG").is_ok() {
280 env_logger::init();
281 }
282 }
283
284 #[gpui::test]
285 async fn test_matching_paths(cx: &mut gpui::TestAppContext) {
286 let app_state = cx.update(|cx| {
287 super::init(cx);
288 editor::init(cx);
289 AppState::test(cx)
290 });
291
292 app_state
293 .fs
294 .as_fake()
295 .insert_tree(
296 "/root",
297 json!({
298 "a": {
299 "banana": "",
300 "bandana": "",
301 }
302 }),
303 )
304 .await;
305
306 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
307 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
308 cx.dispatch_action(window_id, Toggle);
309
310 let finder = cx.read(|cx| workspace.read(cx).modal::<FileFinder>().unwrap());
311 finder
312 .update(cx, |finder, cx| {
313 finder.delegate_mut().update_matches("bna".to_string(), cx)
314 })
315 .await;
316 finder.read_with(cx, |finder, _| {
317 assert_eq!(finder.delegate().matches.len(), 2);
318 });
319
320 let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone());
321 cx.dispatch_action(window_id, SelectNext);
322 cx.dispatch_action(window_id, Confirm);
323 active_pane
324 .condition(cx, |pane, _| pane.active_item().is_some())
325 .await;
326 cx.read(|cx| {
327 let active_item = active_pane.read(cx).active_item().unwrap();
328 assert_eq!(
329 active_item
330 .as_any()
331 .downcast_ref::<Editor>()
332 .unwrap()
333 .read(cx)
334 .title(cx),
335 "bandana"
336 );
337 });
338 }
339
340 #[gpui::test]
341 async fn test_matching_cancellation(cx: &mut gpui::TestAppContext) {
342 let app_state = cx.update(AppState::test);
343 app_state
344 .fs
345 .as_fake()
346 .insert_tree(
347 "/dir",
348 json!({
349 "hello": "",
350 "goodbye": "",
351 "halogen-light": "",
352 "happiness": "",
353 "height": "",
354 "hi": "",
355 "hiccup": "",
356 }),
357 )
358 .await;
359
360 let project = Project::test(app_state.fs.clone(), ["/dir".as_ref()], cx).await;
361 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
362 let (_, finder) = cx.add_window(|cx| {
363 Picker::new(
364 FileFinderDelegate::new(
365 workspace.downgrade(),
366 workspace.read(cx).project().clone(),
367 None,
368 cx,
369 ),
370 cx,
371 )
372 });
373
374 let query = "hi".to_string();
375 finder
376 .update(cx, |f, cx| f.delegate_mut().spawn_search(query.clone(), cx))
377 .await;
378 finder.read_with(cx, |f, _| assert_eq!(f.delegate().matches.len(), 5));
379
380 finder.update(cx, |finder, cx| {
381 let delegate = finder.delegate_mut();
382 let matches = delegate.matches.clone();
383
384 // Simulate a search being cancelled after the time limit,
385 // returning only a subset of the matches that would have been found.
386 drop(delegate.spawn_search(query.clone(), cx));
387 delegate.set_matches(
388 delegate.latest_search_id,
389 true, // did-cancel
390 query.clone(),
391 vec![matches[1].clone(), matches[3].clone()],
392 cx,
393 );
394
395 // Simulate another cancellation.
396 drop(delegate.spawn_search(query.clone(), cx));
397 delegate.set_matches(
398 delegate.latest_search_id,
399 true, // did-cancel
400 query.clone(),
401 vec![matches[0].clone(), matches[2].clone(), matches[3].clone()],
402 cx,
403 );
404
405 assert_eq!(delegate.matches, matches[0..4])
406 });
407 }
408
409 #[gpui::test]
410 async fn test_ignored_files(cx: &mut gpui::TestAppContext) {
411 let app_state = cx.update(AppState::test);
412 app_state
413 .fs
414 .as_fake()
415 .insert_tree(
416 "/ancestor",
417 json!({
418 ".gitignore": "ignored-root",
419 "ignored-root": {
420 "happiness": "",
421 "height": "",
422 "hi": "",
423 "hiccup": "",
424 },
425 "tracked-root": {
426 ".gitignore": "height",
427 "happiness": "",
428 "height": "",
429 "hi": "",
430 "hiccup": "",
431 },
432 }),
433 )
434 .await;
435
436 let project = Project::test(
437 app_state.fs.clone(),
438 [
439 "/ancestor/tracked-root".as_ref(),
440 "/ancestor/ignored-root".as_ref(),
441 ],
442 cx,
443 )
444 .await;
445 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
446 let (_, finder) = cx.add_window(|cx| {
447 Picker::new(
448 FileFinderDelegate::new(
449 workspace.downgrade(),
450 workspace.read(cx).project().clone(),
451 None,
452 cx,
453 ),
454 cx,
455 )
456 });
457 finder
458 .update(cx, |f, cx| f.delegate_mut().spawn_search("hi".into(), cx))
459 .await;
460 finder.read_with(cx, |f, _| assert_eq!(f.delegate().matches.len(), 7));
461 }
462
463 #[gpui::test]
464 async fn test_single_file_worktrees(cx: &mut gpui::TestAppContext) {
465 let app_state = cx.update(AppState::test);
466 app_state
467 .fs
468 .as_fake()
469 .insert_tree("/root", json!({ "the-parent-dir": { "the-file": "" } }))
470 .await;
471
472 let project = Project::test(
473 app_state.fs.clone(),
474 ["/root/the-parent-dir/the-file".as_ref()],
475 cx,
476 )
477 .await;
478 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
479 let (_, finder) = cx.add_window(|cx| {
480 Picker::new(
481 FileFinderDelegate::new(
482 workspace.downgrade(),
483 workspace.read(cx).project().clone(),
484 None,
485 cx,
486 ),
487 cx,
488 )
489 });
490
491 // Even though there is only one worktree, that worktree's filename
492 // is included in the matching, because the worktree is a single file.
493 finder
494 .update(cx, |f, cx| f.delegate_mut().spawn_search("thf".into(), cx))
495 .await;
496 cx.read(|cx| {
497 let finder = finder.read(cx);
498 let delegate = finder.delegate();
499 assert_eq!(delegate.matches.len(), 1);
500
501 let (file_name, file_name_positions, full_path, full_path_positions) =
502 delegate.labels_for_match(&delegate.matches[0]);
503 assert_eq!(file_name, "the-file");
504 assert_eq!(file_name_positions, &[0, 1, 4]);
505 assert_eq!(full_path, "the-file");
506 assert_eq!(full_path_positions, &[0, 1, 4]);
507 });
508
509 // Since the worktree root is a file, searching for its name followed by a slash does
510 // not match anything.
511 finder
512 .update(cx, |f, cx| f.delegate_mut().spawn_search("thf/".into(), cx))
513 .await;
514 finder.read_with(cx, |f, _| assert_eq!(f.delegate().matches.len(), 0));
515 }
516
517 #[gpui::test]
518 async fn test_multiple_matches_with_same_relative_path(cx: &mut gpui::TestAppContext) {
519 cx.foreground().forbid_parking();
520
521 let app_state = cx.update(AppState::test);
522 app_state
523 .fs
524 .as_fake()
525 .insert_tree(
526 "/root",
527 json!({
528 "dir1": { "a.txt": "" },
529 "dir2": { "a.txt": "" }
530 }),
531 )
532 .await;
533
534 let project = Project::test(
535 app_state.fs.clone(),
536 ["/root/dir1".as_ref(), "/root/dir2".as_ref()],
537 cx,
538 )
539 .await;
540 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
541
542 let (_, finder) = cx.add_window(|cx| {
543 Picker::new(
544 FileFinderDelegate::new(
545 workspace.downgrade(),
546 workspace.read(cx).project().clone(),
547 None,
548 cx,
549 ),
550 cx,
551 )
552 });
553
554 // Run a search that matches two files with the same relative path.
555 finder
556 .update(cx, |f, cx| f.delegate_mut().spawn_search("a.t".into(), cx))
557 .await;
558
559 // Can switch between different matches with the same relative path.
560 finder.update(cx, |finder, cx| {
561 let delegate = finder.delegate_mut();
562 assert_eq!(delegate.matches.len(), 2);
563 assert_eq!(delegate.selected_index(), 0);
564 delegate.set_selected_index(1, cx);
565 assert_eq!(delegate.selected_index(), 1);
566 delegate.set_selected_index(0, cx);
567 assert_eq!(delegate.selected_index(), 0);
568 });
569 }
570
571 #[gpui::test]
572 async fn test_path_distance_ordering(cx: &mut gpui::TestAppContext) {
573 cx.foreground().forbid_parking();
574
575 let app_state = cx.update(AppState::test);
576 app_state
577 .fs
578 .as_fake()
579 .insert_tree(
580 "/root",
581 json!({
582 "dir1": { "a.txt": "" },
583 "dir2": {
584 "a.txt": "",
585 "b.txt": ""
586 }
587 }),
588 )
589 .await;
590
591 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
592 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
593
594 // When workspace has an active item, sort items which are closer to that item
595 // first when they have the same name. In this case, b.txt is closer to dir2's a.txt
596 // so that one should be sorted earlier
597 let b_path = Some(Arc::from(Path::new("/root/dir2/b.txt")));
598 let (_, finder) = cx.add_window(|cx| {
599 Picker::new(
600 FileFinderDelegate::new(
601 workspace.downgrade(),
602 workspace.read(cx).project().clone(),
603 b_path,
604 cx,
605 ),
606 cx,
607 )
608 });
609
610 finder
611 .update(cx, |f, cx| {
612 f.delegate_mut().spawn_search("a.txt".into(), cx)
613 })
614 .await;
615
616 finder.read_with(cx, |f, _| {
617 let delegate = f.delegate();
618 assert_eq!(delegate.matches[0].path.as_ref(), Path::new("dir2/a.txt"));
619 assert_eq!(delegate.matches[1].path.as_ref(), Path::new("dir1/a.txt"));
620 });
621 }
622
623 #[gpui::test]
624 async fn test_search_worktree_without_files(cx: &mut gpui::TestAppContext) {
625 let app_state = cx.update(AppState::test);
626 app_state
627 .fs
628 .as_fake()
629 .insert_tree(
630 "/root",
631 json!({
632 "dir1": {},
633 "dir2": {
634 "dir3": {}
635 }
636 }),
637 )
638 .await;
639
640 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
641 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
642 let (_, finder) = cx.add_window(|cx| {
643 Picker::new(
644 FileFinderDelegate::new(
645 workspace.downgrade(),
646 workspace.read(cx).project().clone(),
647 None,
648 cx,
649 ),
650 cx,
651 )
652 });
653 finder
654 .update(cx, |f, cx| f.delegate_mut().spawn_search("dir".into(), cx))
655 .await;
656 cx.read(|cx| {
657 let finder = finder.read(cx);
658 assert_eq!(finder.delegate().matches.len(), 0);
659 });
660 }
661}