1#[cfg(test)]
2mod file_finder_tests;
3
4use collections::HashMap;
5use editor::{scroll::Autoscroll, Bias, Editor};
6use fuzzy::{CharBag, PathMatch, PathMatchCandidate};
7use gpui::{
8 actions, rems, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, Model,
9 ParentElement, Render, Styled, Task, View, ViewContext, VisualContext, WeakView,
10};
11use picker::{Picker, PickerDelegate};
12use project::{PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
13use std::{
14 path::{Path, PathBuf},
15 sync::{
16 atomic::{self, AtomicBool},
17 Arc,
18 },
19};
20use text::Point;
21use ui::{prelude::*, HighlightedLabel, ListItem, ListItemSpacing};
22use util::{paths::PathLikeWithPosition, post_inc, ResultExt};
23use workspace::{ModalView, Workspace};
24
25actions!(file_finder, [Toggle]);
26
27impl ModalView for FileFinder {}
28
29pub struct FileFinder {
30 picker: View<Picker<FileFinderDelegate>>,
31}
32
33pub fn init(cx: &mut AppContext) {
34 cx.observe_new_views(FileFinder::register).detach();
35}
36
37impl FileFinder {
38 fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
39 workspace.register_action(|workspace, _: &Toggle, cx| {
40 let Some(file_finder) = workspace.active_modal::<Self>(cx) else {
41 Self::open(workspace, cx);
42 return;
43 };
44
45 file_finder.update(cx, |file_finder, cx| {
46 file_finder
47 .picker
48 .update(cx, |picker, cx| picker.cycle_selection(cx))
49 });
50 });
51 }
52
53 fn open(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) {
54 let project = workspace.project().read(cx);
55
56 let currently_opened_path = workspace
57 .active_item(cx)
58 .and_then(|item| item.project_path(cx))
59 .map(|project_path| {
60 let abs_path = project
61 .worktree_for_id(project_path.worktree_id, cx)
62 .map(|worktree| worktree.read(cx).abs_path().join(&project_path.path));
63 FoundPath::new(project_path, abs_path)
64 });
65
66 // if exists, bubble the currently opened path to the top
67 let history_items = currently_opened_path
68 .clone()
69 .into_iter()
70 .chain(
71 workspace
72 .recent_navigation_history(Some(MAX_RECENT_SELECTIONS), cx)
73 .into_iter()
74 .filter(|(history_path, _)| {
75 Some(history_path)
76 != currently_opened_path
77 .as_ref()
78 .map(|found_path| &found_path.project)
79 })
80 .filter(|(_, history_abs_path)| {
81 history_abs_path.as_ref()
82 != currently_opened_path
83 .as_ref()
84 .and_then(|found_path| found_path.absolute.as_ref())
85 })
86 .filter(|(_, history_abs_path)| match history_abs_path {
87 Some(abs_path) => history_file_exists(abs_path),
88 None => true,
89 })
90 .map(|(history_path, abs_path)| FoundPath::new(history_path, abs_path)),
91 )
92 .collect();
93
94 let project = workspace.project().clone();
95 let weak_workspace = cx.view().downgrade();
96 workspace.toggle_modal(cx, |cx| {
97 let delegate = FileFinderDelegate::new(
98 cx.view().downgrade(),
99 weak_workspace,
100 project,
101 currently_opened_path,
102 history_items,
103 cx,
104 );
105
106 FileFinder::new(delegate, cx)
107 });
108 }
109
110 fn new(delegate: FileFinderDelegate, cx: &mut ViewContext<Self>) -> Self {
111 Self {
112 picker: cx.new_view(|cx| Picker::new(delegate, cx)),
113 }
114 }
115}
116
117impl EventEmitter<DismissEvent> for FileFinder {}
118
119impl FocusableView for FileFinder {
120 fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
121 self.picker.focus_handle(cx)
122 }
123}
124
125impl Render for FileFinder {
126 fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
127 v_flex().w(rems(34.)).child(self.picker.clone())
128 }
129}
130
131pub struct FileFinderDelegate {
132 file_finder: WeakView<FileFinder>,
133 workspace: WeakView<Workspace>,
134 project: Model<Project>,
135 search_count: usize,
136 latest_search_id: usize,
137 latest_search_did_cancel: bool,
138 latest_search_query: Option<PathLikeWithPosition<FileSearchQuery>>,
139 currently_opened_path: Option<FoundPath>,
140 matches: Matches,
141 selected_index: Option<usize>,
142 cancel_flag: Arc<AtomicBool>,
143 history_items: Vec<FoundPath>,
144}
145
146#[derive(Debug, Default)]
147struct Matches {
148 history: Vec<(FoundPath, Option<PathMatch>)>,
149 search: Vec<PathMatch>,
150}
151
152#[derive(Debug)]
153enum Match<'a> {
154 History(&'a FoundPath, Option<&'a PathMatch>),
155 Search(&'a PathMatch),
156}
157
158impl Matches {
159 fn len(&self) -> usize {
160 self.history.len() + self.search.len()
161 }
162
163 fn get(&self, index: usize) -> Option<Match<'_>> {
164 if index < self.history.len() {
165 self.history
166 .get(index)
167 .map(|(path, path_match)| Match::History(path, path_match.as_ref()))
168 } else {
169 self.search
170 .get(index - self.history.len())
171 .map(Match::Search)
172 }
173 }
174
175 fn push_new_matches(
176 &mut self,
177 history_items: &Vec<FoundPath>,
178 query: &PathLikeWithPosition<FileSearchQuery>,
179 mut new_search_matches: Vec<PathMatch>,
180 extend_old_matches: bool,
181 ) {
182 let matching_history_paths = matching_history_item_paths(history_items, query);
183 new_search_matches
184 .retain(|path_match| !matching_history_paths.contains_key(&path_match.path));
185 let history_items_to_show = history_items
186 .iter()
187 .filter_map(|history_item| {
188 Some((
189 history_item.clone(),
190 Some(
191 matching_history_paths
192 .get(&history_item.project.path)?
193 .clone(),
194 ),
195 ))
196 })
197 .collect::<Vec<_>>();
198 self.history = history_items_to_show;
199 if extend_old_matches {
200 self.search
201 .retain(|path_match| !matching_history_paths.contains_key(&path_match.path));
202 util::extend_sorted(
203 &mut self.search,
204 new_search_matches.into_iter(),
205 100,
206 |a, b| b.cmp(a),
207 )
208 } else {
209 self.search = new_search_matches;
210 }
211 }
212}
213
214fn matching_history_item_paths(
215 history_items: &Vec<FoundPath>,
216 query: &PathLikeWithPosition<FileSearchQuery>,
217) -> HashMap<Arc<Path>, PathMatch> {
218 let history_items_by_worktrees = history_items
219 .iter()
220 .filter_map(|found_path| {
221 let candidate = PathMatchCandidate {
222 path: &found_path.project.path,
223 // Only match history items names, otherwise their paths may match too many queries, producing false positives.
224 // E.g. `foo` would match both `something/foo/bar.rs` and `something/foo/foo.rs` and if the former is a history item,
225 // it would be shown first always, despite the latter being a better match.
226 char_bag: CharBag::from_iter(
227 found_path
228 .project
229 .path
230 .file_name()?
231 .to_string_lossy()
232 .to_lowercase()
233 .chars(),
234 ),
235 };
236 Some((found_path.project.worktree_id, candidate))
237 })
238 .fold(
239 HashMap::default(),
240 |mut candidates, (worktree_id, new_candidate)| {
241 candidates
242 .entry(worktree_id)
243 .or_insert_with(Vec::new)
244 .push(new_candidate);
245 candidates
246 },
247 );
248 let mut matching_history_paths = HashMap::default();
249 for (worktree, candidates) in history_items_by_worktrees {
250 let max_results = candidates.len() + 1;
251 matching_history_paths.extend(
252 fuzzy::match_fixed_path_set(
253 candidates,
254 worktree.to_usize(),
255 query.path_like.path_query(),
256 false,
257 max_results,
258 )
259 .into_iter()
260 .map(|path_match| (Arc::clone(&path_match.path), path_match)),
261 );
262 }
263 matching_history_paths
264}
265
266#[derive(Debug, Clone, PartialEq, Eq)]
267struct FoundPath {
268 project: ProjectPath,
269 absolute: Option<PathBuf>,
270}
271
272impl FoundPath {
273 fn new(project: ProjectPath, absolute: Option<PathBuf>) -> Self {
274 Self { project, absolute }
275 }
276}
277
278const MAX_RECENT_SELECTIONS: usize = 20;
279
280#[cfg(not(test))]
281fn history_file_exists(abs_path: &PathBuf) -> bool {
282 abs_path.exists()
283}
284
285#[cfg(test)]
286fn history_file_exists(abs_path: &PathBuf) -> bool {
287 !abs_path.ends_with("nonexistent.rs")
288}
289
290pub enum Event {
291 Selected(ProjectPath),
292 Dismissed,
293}
294
295#[derive(Debug, Clone)]
296struct FileSearchQuery {
297 raw_query: String,
298 file_query_end: Option<usize>,
299}
300
301impl FileSearchQuery {
302 fn path_query(&self) -> &str {
303 match self.file_query_end {
304 Some(file_path_end) => &self.raw_query[..file_path_end],
305 None => &self.raw_query,
306 }
307 }
308}
309
310impl FileFinderDelegate {
311 fn new(
312 file_finder: WeakView<FileFinder>,
313 workspace: WeakView<Workspace>,
314 project: Model<Project>,
315 currently_opened_path: Option<FoundPath>,
316 history_items: Vec<FoundPath>,
317 cx: &mut ViewContext<FileFinder>,
318 ) -> Self {
319 cx.observe(&project, |file_finder, _, cx| {
320 //todo We should probably not re-render on every project anything
321 file_finder
322 .picker
323 .update(cx, |picker, cx| picker.refresh(cx))
324 })
325 .detach();
326
327 Self {
328 file_finder,
329 workspace,
330 project,
331 search_count: 0,
332 latest_search_id: 0,
333 latest_search_did_cancel: false,
334 latest_search_query: None,
335 currently_opened_path,
336 matches: Matches::default(),
337 selected_index: None,
338 cancel_flag: Arc::new(AtomicBool::new(false)),
339 history_items,
340 }
341 }
342
343 fn spawn_search(
344 &mut self,
345 query: PathLikeWithPosition<FileSearchQuery>,
346 cx: &mut ViewContext<Picker<Self>>,
347 ) -> Task<()> {
348 let relative_to = self
349 .currently_opened_path
350 .as_ref()
351 .map(|found_path| Arc::clone(&found_path.project.path));
352 let worktrees = self
353 .project
354 .read(cx)
355 .visible_worktrees(cx)
356 .collect::<Vec<_>>();
357 let include_root_name = worktrees.len() > 1;
358 let candidate_sets = worktrees
359 .into_iter()
360 .map(|worktree| {
361 let worktree = worktree.read(cx);
362 PathMatchCandidateSet {
363 snapshot: worktree.snapshot(),
364 include_ignored: worktree
365 .root_entry()
366 .map_or(false, |entry| entry.is_ignored),
367 include_root_name,
368 }
369 })
370 .collect::<Vec<_>>();
371
372 let search_id = util::post_inc(&mut self.search_count);
373 self.cancel_flag.store(true, atomic::Ordering::Relaxed);
374 self.cancel_flag = Arc::new(AtomicBool::new(false));
375 let cancel_flag = self.cancel_flag.clone();
376 cx.spawn(|picker, mut cx| async move {
377 let matches = fuzzy::match_path_sets(
378 candidate_sets.as_slice(),
379 query.path_like.path_query(),
380 relative_to,
381 false,
382 100,
383 &cancel_flag,
384 cx.background_executor().clone(),
385 )
386 .await;
387 let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed);
388 picker
389 .update(&mut cx, |picker, cx| {
390 picker.delegate.selected_index.take();
391 picker
392 .delegate
393 .set_search_matches(search_id, did_cancel, query, matches, cx)
394 })
395 .log_err();
396 })
397 }
398
399 fn set_search_matches(
400 &mut self,
401 search_id: usize,
402 did_cancel: bool,
403 query: PathLikeWithPosition<FileSearchQuery>,
404 matches: Vec<PathMatch>,
405 cx: &mut ViewContext<Picker<Self>>,
406 ) {
407 if search_id >= self.latest_search_id {
408 self.latest_search_id = search_id;
409 let extend_old_matches = self.latest_search_did_cancel
410 && Some(query.path_like.path_query())
411 == self
412 .latest_search_query
413 .as_ref()
414 .map(|query| query.path_like.path_query());
415 self.matches
416 .push_new_matches(&self.history_items, &query, matches, extend_old_matches);
417 self.latest_search_query = Some(query);
418 self.latest_search_did_cancel = did_cancel;
419 cx.notify();
420 }
421 }
422
423 fn labels_for_match(
424 &self,
425 path_match: Match,
426 cx: &AppContext,
427 ix: usize,
428 ) -> (String, Vec<usize>, String, Vec<usize>) {
429 let (file_name, file_name_positions, full_path, full_path_positions) = match path_match {
430 Match::History(found_path, found_path_match) => {
431 let worktree_id = found_path.project.worktree_id;
432 let project_relative_path = &found_path.project.path;
433 let has_worktree = self
434 .project
435 .read(cx)
436 .worktree_for_id(worktree_id, cx)
437 .is_some();
438
439 if !has_worktree {
440 if let Some(absolute_path) = &found_path.absolute {
441 return (
442 absolute_path
443 .file_name()
444 .map_or_else(
445 || project_relative_path.to_string_lossy(),
446 |file_name| file_name.to_string_lossy(),
447 )
448 .to_string(),
449 Vec::new(),
450 absolute_path.to_string_lossy().to_string(),
451 Vec::new(),
452 );
453 }
454 }
455
456 let mut path = Arc::clone(project_relative_path);
457 if project_relative_path.as_ref() == Path::new("") {
458 if let Some(absolute_path) = &found_path.absolute {
459 path = Arc::from(absolute_path.as_path());
460 }
461 }
462
463 let mut path_match = PathMatch {
464 score: ix as f64,
465 positions: Vec::new(),
466 worktree_id: worktree_id.to_usize(),
467 path,
468 path_prefix: "".into(),
469 distance_to_relative_ancestor: usize::MAX,
470 };
471 if let Some(found_path_match) = found_path_match {
472 path_match
473 .positions
474 .extend(found_path_match.positions.iter())
475 }
476
477 self.labels_for_path_match(&path_match)
478 }
479 Match::Search(path_match) => self.labels_for_path_match(path_match),
480 };
481
482 if file_name_positions.is_empty() {
483 if let Some(user_home_path) = std::env::var("HOME").ok() {
484 let user_home_path = user_home_path.trim();
485 if !user_home_path.is_empty() {
486 if (&full_path).starts_with(user_home_path) {
487 return (
488 file_name,
489 file_name_positions,
490 full_path.replace(user_home_path, "~"),
491 full_path_positions,
492 );
493 }
494 }
495 }
496 }
497
498 (
499 file_name,
500 file_name_positions,
501 full_path,
502 full_path_positions,
503 )
504 }
505
506 fn labels_for_path_match(
507 &self,
508 path_match: &PathMatch,
509 ) -> (String, Vec<usize>, String, Vec<usize>) {
510 let path = &path_match.path;
511 let path_string = path.to_string_lossy();
512 let full_path = [path_match.path_prefix.as_ref(), path_string.as_ref()].join("");
513 let path_positions = path_match.positions.clone();
514
515 let file_name = path.file_name().map_or_else(
516 || path_match.path_prefix.to_string(),
517 |file_name| file_name.to_string_lossy().to_string(),
518 );
519 let file_name_start = path_match.path_prefix.len() + path_string.len() - file_name.len();
520 let file_name_positions = path_positions
521 .iter()
522 .filter_map(|pos| {
523 if pos >= &file_name_start {
524 Some(pos - file_name_start)
525 } else {
526 None
527 }
528 })
529 .collect();
530
531 (file_name, file_name_positions, full_path, path_positions)
532 }
533
534 fn lookup_absolute_path(
535 &self,
536 query: PathLikeWithPosition<FileSearchQuery>,
537 cx: &mut ViewContext<'_, Picker<Self>>,
538 ) -> Task<()> {
539 cx.spawn(|picker, mut cx| async move {
540 let Some((project, fs)) = picker
541 .update(&mut cx, |picker, cx| {
542 let fs = Arc::clone(&picker.delegate.project.read(cx).fs());
543 (picker.delegate.project.clone(), fs)
544 })
545 .log_err()
546 else {
547 return;
548 };
549
550 let query_path = Path::new(query.path_like.path_query());
551 let mut path_matches = Vec::new();
552 match fs.metadata(query_path).await.log_err() {
553 Some(Some(_metadata)) => {
554 let update_result = project
555 .update(&mut cx, |project, cx| {
556 if let Some((worktree, relative_path)) =
557 project.find_local_worktree(query_path, cx)
558 {
559 path_matches.push(PathMatch {
560 score: 0.0,
561 positions: Vec::new(),
562 worktree_id: worktree.read(cx).id().to_usize(),
563 path: Arc::from(relative_path),
564 path_prefix: "".into(),
565 distance_to_relative_ancestor: usize::MAX,
566 });
567 }
568 })
569 .log_err();
570 if update_result.is_none() {
571 return;
572 }
573 }
574 Some(None) => {}
575 None => return,
576 }
577
578 picker
579 .update(&mut cx, |picker, cx| {
580 let picker_delegate = &mut picker.delegate;
581 let search_id = util::post_inc(&mut picker_delegate.search_count);
582 picker_delegate.set_search_matches(search_id, false, query, path_matches, cx);
583
584 anyhow::Ok(())
585 })
586 .log_err();
587 })
588 }
589}
590
591impl PickerDelegate for FileFinderDelegate {
592 type ListItem = ListItem;
593
594 fn placeholder_text(&self) -> Arc<str> {
595 "Search project files...".into()
596 }
597
598 fn match_count(&self) -> usize {
599 self.matches.len()
600 }
601
602 fn selected_index(&self) -> usize {
603 self.selected_index.unwrap_or(0)
604 }
605
606 fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Picker<Self>>) {
607 self.selected_index = Some(ix);
608 cx.notify();
609 }
610
611 fn separators_after_indices(&self) -> Vec<usize> {
612 let history_items = self.matches.history.len();
613 if history_items == 0 || self.matches.search.is_empty() {
614 Vec::new()
615 } else {
616 vec![history_items - 1]
617 }
618 }
619
620 fn update_matches(
621 &mut self,
622 raw_query: String,
623 cx: &mut ViewContext<Picker<Self>>,
624 ) -> Task<()> {
625 let raw_query = raw_query.trim();
626 if raw_query.is_empty() {
627 let project = self.project.read(cx);
628 self.latest_search_id = post_inc(&mut self.search_count);
629 self.selected_index.take();
630 self.matches = Matches {
631 history: self
632 .history_items
633 .iter()
634 .filter(|history_item| {
635 project
636 .worktree_for_id(history_item.project.worktree_id, cx)
637 .is_some()
638 || (project.is_local() && history_item.absolute.is_some())
639 })
640 .cloned()
641 .map(|p| (p, None))
642 .collect(),
643 search: Vec::new(),
644 };
645 cx.notify();
646 Task::ready(())
647 } else {
648 let query = PathLikeWithPosition::parse_str(raw_query, |path_like_str| {
649 Ok::<_, std::convert::Infallible>(FileSearchQuery {
650 raw_query: raw_query.to_owned(),
651 file_query_end: if path_like_str == raw_query {
652 None
653 } else {
654 Some(path_like_str.len())
655 },
656 })
657 })
658 .expect("infallible");
659
660 if Path::new(query.path_like.path_query()).is_absolute() {
661 self.lookup_absolute_path(query, cx)
662 } else {
663 self.spawn_search(query, cx)
664 }
665 }
666 }
667
668 fn confirm(&mut self, secondary: bool, cx: &mut ViewContext<Picker<FileFinderDelegate>>) {
669 if let Some(m) = self.matches.get(self.selected_index()) {
670 if let Some(workspace) = self.workspace.upgrade() {
671 let open_task = workspace.update(cx, move |workspace, cx| {
672 let split_or_open = |workspace: &mut Workspace, project_path, cx| {
673 if secondary {
674 workspace.split_path(project_path, cx)
675 } else {
676 workspace.open_path(project_path, None, true, cx)
677 }
678 };
679 match m {
680 Match::History(history_match, _) => {
681 let worktree_id = history_match.project.worktree_id;
682 if workspace
683 .project()
684 .read(cx)
685 .worktree_for_id(worktree_id, cx)
686 .is_some()
687 {
688 split_or_open(
689 workspace,
690 ProjectPath {
691 worktree_id,
692 path: Arc::clone(&history_match.project.path),
693 },
694 cx,
695 )
696 } else {
697 match history_match.absolute.as_ref() {
698 Some(abs_path) => {
699 if secondary {
700 workspace.split_abs_path(
701 abs_path.to_path_buf(),
702 false,
703 cx,
704 )
705 } else {
706 workspace.open_abs_path(
707 abs_path.to_path_buf(),
708 false,
709 cx,
710 )
711 }
712 }
713 None => split_or_open(
714 workspace,
715 ProjectPath {
716 worktree_id,
717 path: Arc::clone(&history_match.project.path),
718 },
719 cx,
720 ),
721 }
722 }
723 }
724 Match::Search(m) => split_or_open(
725 workspace,
726 ProjectPath {
727 worktree_id: WorktreeId::from_usize(m.worktree_id),
728 path: m.path.clone(),
729 },
730 cx,
731 ),
732 }
733 });
734
735 let row = self
736 .latest_search_query
737 .as_ref()
738 .and_then(|query| query.row)
739 .map(|row| row.saturating_sub(1));
740 let col = self
741 .latest_search_query
742 .as_ref()
743 .and_then(|query| query.column)
744 .unwrap_or(0)
745 .saturating_sub(1);
746 let finder = self.file_finder.clone();
747
748 cx.spawn(|_, mut cx| async move {
749 let item = open_task.await.log_err()?;
750 if let Some(row) = row {
751 if let Some(active_editor) = item.downcast::<Editor>() {
752 active_editor
753 .downgrade()
754 .update(&mut cx, |editor, cx| {
755 let snapshot = editor.snapshot(cx).display_snapshot;
756 let point = snapshot
757 .buffer_snapshot
758 .clip_point(Point::new(row, col), Bias::Left);
759 editor.change_selections(Some(Autoscroll::center()), cx, |s| {
760 s.select_ranges([point..point])
761 });
762 })
763 .log_err();
764 }
765 }
766 finder.update(&mut cx, |_, cx| cx.emit(DismissEvent)).ok()?;
767
768 Some(())
769 })
770 .detach();
771 }
772 }
773 }
774
775 fn dismissed(&mut self, cx: &mut ViewContext<Picker<FileFinderDelegate>>) {
776 self.file_finder
777 .update(cx, |_, cx| cx.emit(DismissEvent))
778 .log_err();
779 }
780
781 fn render_match(
782 &self,
783 ix: usize,
784 selected: bool,
785 cx: &mut ViewContext<Picker<Self>>,
786 ) -> Option<Self::ListItem> {
787 let path_match = self
788 .matches
789 .get(ix)
790 .expect("Invalid matches state: no element for index {ix}");
791
792 let (file_name, file_name_positions, full_path, full_path_positions) =
793 self.labels_for_match(path_match, cx, ix);
794
795 Some(
796 ListItem::new(ix)
797 .spacing(ListItemSpacing::Sparse)
798 .inset(true)
799 .selected(selected)
800 .child(
801 v_flex()
802 .child(HighlightedLabel::new(file_name, file_name_positions))
803 .child(HighlightedLabel::new(full_path, full_path_positions)),
804 ),
805 )
806 }
807}