1#[cfg(test)]
2mod file_finder_tests;
3
4mod file_finder_settings;
5mod new_path_prompt;
6mod open_path_prompt;
7
8use collections::HashMap;
9use editor::{scroll::Autoscroll, Bias, Editor};
10use file_finder_settings::FileFinderSettings;
11use file_icons::FileIcons;
12use fuzzy::{CharBag, PathMatch, PathMatchCandidate};
13use gpui::{
14 actions, rems, Action, AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle,
15 FocusableView, Model, Modifiers, ModifiersChangedEvent, ParentElement, Render, Styled, Task,
16 View, ViewContext, VisualContext, WeakView,
17};
18use new_path_prompt::NewPathPrompt;
19use open_path_prompt::OpenPathPrompt;
20use picker::{Picker, PickerDelegate};
21use project::{PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
22use settings::Settings;
23use std::{
24 cmp,
25 path::{Path, PathBuf},
26 sync::{
27 atomic::{self, AtomicBool},
28 Arc,
29 },
30};
31use text::Point;
32use ui::{prelude::*, HighlightedLabel, ListItem, ListItemSpacing};
33use util::{paths::PathWithPosition, post_inc, ResultExt};
34use workspace::{item::PreviewTabsSettings, notifications::NotifyResultExt, ModalView, Workspace};
35
36actions!(file_finder, [SelectPrev]);
37
38impl ModalView for FileFinder {}
39
40pub struct FileFinder {
41 picker: View<Picker<FileFinderDelegate>>,
42 init_modifiers: Option<Modifiers>,
43}
44
45pub fn init_settings(cx: &mut AppContext) {
46 FileFinderSettings::register(cx);
47}
48
49pub fn init(cx: &mut AppContext) {
50 init_settings(cx);
51 cx.observe_new_views(FileFinder::register).detach();
52 cx.observe_new_views(NewPathPrompt::register).detach();
53 cx.observe_new_views(OpenPathPrompt::register).detach();
54}
55
56impl FileFinder {
57 fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
58 workspace.register_action(|workspace, action: &workspace::ToggleFileFinder, cx| {
59 let Some(file_finder) = workspace.active_modal::<Self>(cx) else {
60 Self::open(workspace, action.separate_history, cx);
61 return;
62 };
63
64 file_finder.update(cx, |file_finder, cx| {
65 file_finder.init_modifiers = Some(cx.modifiers());
66 file_finder.picker.update(cx, |picker, cx| {
67 picker.cycle_selection(cx);
68 });
69 });
70 });
71 }
72
73 fn open(workspace: &mut Workspace, separate_history: bool, cx: &mut ViewContext<Workspace>) {
74 let project = workspace.project().read(cx);
75
76 let currently_opened_path = workspace
77 .active_item(cx)
78 .and_then(|item| item.project_path(cx))
79 .map(|project_path| {
80 let abs_path = project
81 .worktree_for_id(project_path.worktree_id, cx)
82 .map(|worktree| worktree.read(cx).abs_path().join(&project_path.path));
83 FoundPath::new(project_path, abs_path)
84 });
85
86 let history_items = workspace
87 .recent_navigation_history(Some(MAX_RECENT_SELECTIONS), cx)
88 .into_iter()
89 .filter(|(_, history_abs_path)| match history_abs_path {
90 Some(abs_path) => history_file_exists(abs_path),
91 None => true,
92 })
93 .map(|(history_path, abs_path)| FoundPath::new(history_path, abs_path))
94 .collect::<Vec<_>>();
95
96 let project = workspace.project().clone();
97 let weak_workspace = cx.view().downgrade();
98 workspace.toggle_modal(cx, |cx| {
99 let delegate = FileFinderDelegate::new(
100 cx.view().downgrade(),
101 weak_workspace,
102 project,
103 currently_opened_path,
104 history_items,
105 separate_history,
106 cx,
107 );
108
109 FileFinder::new(delegate, cx)
110 });
111 }
112
113 fn new(delegate: FileFinderDelegate, cx: &mut ViewContext<Self>) -> Self {
114 Self {
115 picker: cx.new_view(|cx| Picker::uniform_list(delegate, cx)),
116 init_modifiers: cx.modifiers().modified().then_some(cx.modifiers()),
117 }
118 }
119
120 fn handle_modifiers_changed(
121 &mut self,
122 event: &ModifiersChangedEvent,
123 cx: &mut ViewContext<Self>,
124 ) {
125 let Some(init_modifiers) = self.init_modifiers.take() else {
126 return;
127 };
128 if self.picker.read(cx).delegate.has_changed_selected_index {
129 if !event.modified() || !init_modifiers.is_subset_of(&event) {
130 self.init_modifiers = None;
131 cx.dispatch_action(menu::Confirm.boxed_clone());
132 }
133 }
134 }
135
136 fn handle_select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
137 self.init_modifiers = Some(cx.modifiers());
138 cx.dispatch_action(Box::new(menu::SelectPrev));
139 }
140}
141
142impl EventEmitter<DismissEvent> for FileFinder {}
143
144impl FocusableView for FileFinder {
145 fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
146 self.picker.focus_handle(cx)
147 }
148}
149
150impl Render for FileFinder {
151 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
152 v_flex()
153 .key_context("FileFinder")
154 .w(rems(34.))
155 .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
156 .on_action(cx.listener(Self::handle_select_prev))
157 .child(self.picker.clone())
158 }
159}
160
161pub struct FileFinderDelegate {
162 file_finder: WeakView<FileFinder>,
163 workspace: WeakView<Workspace>,
164 project: Model<Project>,
165 search_count: usize,
166 latest_search_id: usize,
167 latest_search_did_cancel: bool,
168 latest_search_query: Option<FileSearchQuery>,
169 currently_opened_path: Option<FoundPath>,
170 matches: Matches,
171 selected_index: usize,
172 has_changed_selected_index: bool,
173 cancel_flag: Arc<AtomicBool>,
174 history_items: Vec<FoundPath>,
175 separate_history: bool,
176 first_update: bool,
177}
178
179/// Use a custom ordering for file finder: the regular one
180/// defines max element with the highest score and the latest alphanumerical path (in case of a tie on other params), e.g:
181/// `[{score: 0.5, path = "c/d" }, { score: 0.5, path = "/a/b" }]`
182///
183/// In the file finder, we would prefer to have the max element with the highest score and the earliest alphanumerical path, e.g:
184/// `[{ score: 0.5, path = "/a/b" }, {score: 0.5, path = "c/d" }]`
185/// as the files are shown in the project panel lists.
186#[derive(Debug, Clone, PartialEq, Eq)]
187struct ProjectPanelOrdMatch(PathMatch);
188
189impl Ord for ProjectPanelOrdMatch {
190 fn cmp(&self, other: &Self) -> cmp::Ordering {
191 self.0
192 .score
193 .partial_cmp(&other.0.score)
194 .unwrap_or(cmp::Ordering::Equal)
195 .then_with(|| self.0.worktree_id.cmp(&other.0.worktree_id))
196 .then_with(|| {
197 other
198 .0
199 .distance_to_relative_ancestor
200 .cmp(&self.0.distance_to_relative_ancestor)
201 })
202 .then_with(|| self.0.path.cmp(&other.0.path).reverse())
203 }
204}
205
206impl PartialOrd for ProjectPanelOrdMatch {
207 fn partial_cmp(&self, other: &Self) -> Option<cmp::Ordering> {
208 Some(self.cmp(other))
209 }
210}
211
212#[derive(Debug, Default)]
213struct Matches {
214 separate_history: bool,
215 matches: Vec<Match>,
216}
217
218#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
219enum Match {
220 History {
221 path: FoundPath,
222 panel_match: Option<ProjectPanelOrdMatch>,
223 },
224 Search(ProjectPanelOrdMatch),
225}
226
227impl Match {
228 fn path(&self) -> &Arc<Path> {
229 match self {
230 Match::History { path, .. } => &path.project.path,
231 Match::Search(panel_match) => &panel_match.0.path,
232 }
233 }
234
235 fn panel_match(&self) -> Option<&ProjectPanelOrdMatch> {
236 match self {
237 Match::History { panel_match, .. } => panel_match.as_ref(),
238 Match::Search(panel_match) => Some(&panel_match),
239 }
240 }
241}
242
243impl Matches {
244 fn len(&self) -> usize {
245 self.matches.len()
246 }
247
248 fn get(&self, index: usize) -> Option<&Match> {
249 self.matches.get(index)
250 }
251
252 fn position(
253 &self,
254 entry: &Match,
255 currently_opened: Option<&FoundPath>,
256 ) -> Result<usize, usize> {
257 if let Match::History {
258 path,
259 panel_match: None,
260 } = entry
261 {
262 // Slow case: linear search by path. Should not happen actually,
263 // since we call `position` only if matches set changed, but the query has not changed.
264 // And History entries do not have panel_match if query is empty, so there's no
265 // reason for the matches set to change.
266 self.matches
267 .iter()
268 .position(|m| path.project.path == *m.path())
269 .ok_or(0)
270 } else {
271 self.matches.binary_search_by(|m| {
272 // `reverse()` since if cmp_matches(a, b) == Ordering::Greater, then a is better than b.
273 // And we want the better entries go first.
274 Self::cmp_matches(self.separate_history, currently_opened, &m, &entry).reverse()
275 })
276 }
277 }
278
279 fn push_new_matches<'a>(
280 &'a mut self,
281 history_items: impl IntoIterator<Item = &'a FoundPath> + Clone,
282 currently_opened: Option<&'a FoundPath>,
283 query: Option<&FileSearchQuery>,
284 new_search_matches: impl Iterator<Item = ProjectPanelOrdMatch>,
285 extend_old_matches: bool,
286 ) {
287 let Some(query) = query else {
288 // assuming that if there's no query, then there's no search matches.
289 self.matches.clear();
290 let path_to_entry = |found_path: &FoundPath| Match::History {
291 path: found_path.clone(),
292 panel_match: None,
293 };
294 self.matches
295 .extend(currently_opened.into_iter().map(path_to_entry));
296
297 self.matches.extend(
298 history_items
299 .into_iter()
300 .filter(|found_path| Some(*found_path) != currently_opened)
301 .map(path_to_entry),
302 );
303 return;
304 };
305
306 let new_history_matches = matching_history_items(history_items, currently_opened, query);
307 let new_search_matches: Vec<Match> = new_search_matches
308 .filter(|path_match| !new_history_matches.contains_key(&path_match.0.path))
309 .map(Match::Search)
310 .collect();
311
312 if extend_old_matches {
313 // since we take history matches instead of new search matches
314 // and history matches has not changed(since the query has not changed and we do not extend old matches otherwise),
315 // old matches can't contain paths present in history_matches as well.
316 self.matches.retain(|m| matches!(m, Match::Search(_)));
317 } else {
318 self.matches.clear();
319 }
320
321 // At this point we have an unsorted set of new history matches, an unsorted set of new search matches
322 // and a sorted set of old search matches.
323 // It is possible that the new search matches' paths contain some of the old search matches' paths.
324 // History matches' paths are unique, since store in a HashMap by path.
325 // We build a sorted Vec<Match>, eliminating duplicate search matches.
326 // Search matches with the same paths should have equal `ProjectPanelOrdMatch`, so we should
327 // not have any duplicates after building the final list.
328 for new_match in new_history_matches
329 .into_values()
330 .chain(new_search_matches.into_iter())
331 {
332 match self.position(&new_match, currently_opened) {
333 Ok(_duplicate) => continue,
334 Err(i) => {
335 self.matches.insert(i, new_match);
336 if self.matches.len() == 100 {
337 break;
338 }
339 }
340 }
341 }
342 }
343
344 /// If a < b, then a is a worse match, aligning with the `ProjectPanelOrdMatch` ordering.
345 fn cmp_matches(
346 separate_history: bool,
347 currently_opened: Option<&FoundPath>,
348 a: &Match,
349 b: &Match,
350 ) -> cmp::Ordering {
351 debug_assert!(a.panel_match().is_some() && b.panel_match().is_some());
352
353 match (&a, &b) {
354 // bubble currently opened files to the top
355 (Match::History { path, .. }, _) if Some(path) == currently_opened => {
356 cmp::Ordering::Greater
357 }
358 (_, Match::History { path, .. }) if Some(path) == currently_opened => {
359 cmp::Ordering::Less
360 }
361
362 (Match::History { .. }, Match::Search(_)) if separate_history => cmp::Ordering::Greater,
363 (Match::Search(_), Match::History { .. }) if separate_history => cmp::Ordering::Less,
364
365 _ => a.panel_match().cmp(&b.panel_match()),
366 }
367 }
368}
369
370fn matching_history_items<'a>(
371 history_items: impl IntoIterator<Item = &'a FoundPath>,
372 currently_opened: Option<&'a FoundPath>,
373 query: &FileSearchQuery,
374) -> HashMap<Arc<Path>, Match> {
375 let mut candidates_paths = HashMap::default();
376
377 let history_items_by_worktrees = history_items
378 .into_iter()
379 .chain(currently_opened)
380 .filter_map(|found_path| {
381 let candidate = PathMatchCandidate {
382 is_dir: false, // You can't open directories as project items
383 path: &found_path.project.path,
384 // Only match history items names, otherwise their paths may match too many queries, producing false positives.
385 // E.g. `foo` would match both `something/foo/bar.rs` and `something/foo/foo.rs` and if the former is a history item,
386 // it would be shown first always, despite the latter being a better match.
387 char_bag: CharBag::from_iter(
388 found_path
389 .project
390 .path
391 .file_name()?
392 .to_string_lossy()
393 .to_lowercase()
394 .chars(),
395 ),
396 };
397 candidates_paths.insert(&found_path.project, found_path);
398 Some((found_path.project.worktree_id, candidate))
399 })
400 .fold(
401 HashMap::default(),
402 |mut candidates, (worktree_id, new_candidate)| {
403 candidates
404 .entry(worktree_id)
405 .or_insert_with(Vec::new)
406 .push(new_candidate);
407 candidates
408 },
409 );
410 let mut matching_history_paths = HashMap::default();
411 for (worktree, candidates) in history_items_by_worktrees {
412 let max_results = candidates.len() + 1;
413 matching_history_paths.extend(
414 fuzzy::match_fixed_path_set(
415 candidates,
416 worktree.to_usize(),
417 query.path_query(),
418 false,
419 max_results,
420 )
421 .into_iter()
422 .filter_map(|path_match| {
423 candidates_paths
424 .remove_entry(&ProjectPath {
425 worktree_id: WorktreeId::from_usize(path_match.worktree_id),
426 path: Arc::clone(&path_match.path),
427 })
428 .map(|(_, found_path)| {
429 (
430 Arc::clone(&path_match.path),
431 Match::History {
432 path: found_path.clone(),
433 panel_match: Some(ProjectPanelOrdMatch(path_match)),
434 },
435 )
436 })
437 }),
438 );
439 }
440 matching_history_paths
441}
442
443#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
444struct FoundPath {
445 project: ProjectPath,
446 absolute: Option<PathBuf>,
447}
448
449impl FoundPath {
450 fn new(project: ProjectPath, absolute: Option<PathBuf>) -> Self {
451 Self { project, absolute }
452 }
453}
454
455const MAX_RECENT_SELECTIONS: usize = 20;
456
457#[cfg(not(test))]
458fn history_file_exists(abs_path: &PathBuf) -> bool {
459 abs_path.exists()
460}
461
462#[cfg(test)]
463fn history_file_exists(abs_path: &Path) -> bool {
464 !abs_path.ends_with("nonexistent.rs")
465}
466
467pub enum Event {
468 Selected(ProjectPath),
469 Dismissed,
470}
471
472#[derive(Debug, Clone)]
473struct FileSearchQuery {
474 raw_query: String,
475 file_query_end: Option<usize>,
476 path_position: PathWithPosition,
477}
478
479impl FileSearchQuery {
480 fn path_query(&self) -> &str {
481 match self.file_query_end {
482 Some(file_path_end) => &self.raw_query[..file_path_end],
483 None => &self.raw_query,
484 }
485 }
486}
487
488impl FileFinderDelegate {
489 fn new(
490 file_finder: WeakView<FileFinder>,
491 workspace: WeakView<Workspace>,
492 project: Model<Project>,
493 currently_opened_path: Option<FoundPath>,
494 history_items: Vec<FoundPath>,
495 separate_history: bool,
496 cx: &mut ViewContext<FileFinder>,
497 ) -> Self {
498 Self::subscribe_to_updates(&project, cx);
499 Self {
500 file_finder,
501 workspace,
502 project,
503 search_count: 0,
504 latest_search_id: 0,
505 latest_search_did_cancel: false,
506 latest_search_query: None,
507 currently_opened_path,
508 matches: Matches::default(),
509 has_changed_selected_index: false,
510 selected_index: 0,
511 cancel_flag: Arc::new(AtomicBool::new(false)),
512 history_items,
513 separate_history,
514 first_update: true,
515 }
516 }
517
518 fn subscribe_to_updates(project: &Model<Project>, cx: &mut ViewContext<FileFinder>) {
519 cx.subscribe(project, |file_finder, _, event, cx| {
520 match event {
521 project::Event::WorktreeUpdatedEntries(_, _)
522 | project::Event::WorktreeAdded
523 | project::Event::WorktreeRemoved(_) => file_finder
524 .picker
525 .update(cx, |picker, cx| picker.refresh(cx)),
526 _ => {}
527 };
528 })
529 .detach();
530 }
531
532 fn spawn_search(
533 &mut self,
534 query: FileSearchQuery,
535 cx: &mut ViewContext<Picker<Self>>,
536 ) -> Task<()> {
537 let relative_to = self
538 .currently_opened_path
539 .as_ref()
540 .map(|found_path| Arc::clone(&found_path.project.path));
541 let worktrees = self
542 .project
543 .read(cx)
544 .visible_worktrees(cx)
545 .collect::<Vec<_>>();
546 let include_root_name = worktrees.len() > 1;
547 let candidate_sets = worktrees
548 .into_iter()
549 .map(|worktree| {
550 let worktree = worktree.read(cx);
551 PathMatchCandidateSet {
552 snapshot: worktree.snapshot(),
553 include_ignored: worktree
554 .root_entry()
555 .map_or(false, |entry| entry.is_ignored),
556 include_root_name,
557 candidates: project::Candidates::Files,
558 }
559 })
560 .collect::<Vec<_>>();
561
562 let search_id = util::post_inc(&mut self.search_count);
563 self.cancel_flag.store(true, atomic::Ordering::Relaxed);
564 self.cancel_flag = Arc::new(AtomicBool::new(false));
565 let cancel_flag = self.cancel_flag.clone();
566 cx.spawn(|picker, mut cx| async move {
567 let matches = fuzzy::match_path_sets(
568 candidate_sets.as_slice(),
569 query.path_query(),
570 relative_to,
571 false,
572 100,
573 &cancel_flag,
574 cx.background_executor().clone(),
575 )
576 .await
577 .into_iter()
578 .map(ProjectPanelOrdMatch);
579 let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed);
580 picker
581 .update(&mut cx, |picker, cx| {
582 picker
583 .delegate
584 .set_search_matches(search_id, did_cancel, query, matches, cx)
585 })
586 .log_err();
587 })
588 }
589
590 fn set_search_matches(
591 &mut self,
592 search_id: usize,
593 did_cancel: bool,
594 query: FileSearchQuery,
595 matches: impl IntoIterator<Item = ProjectPanelOrdMatch>,
596 cx: &mut ViewContext<Picker<Self>>,
597 ) {
598 if search_id >= self.latest_search_id {
599 self.latest_search_id = search_id;
600 let query_changed = Some(query.path_query())
601 != self
602 .latest_search_query
603 .as_ref()
604 .map(|query| query.path_query());
605 let extend_old_matches = self.latest_search_did_cancel && !query_changed;
606
607 let selected_match = if query_changed {
608 None
609 } else {
610 self.matches.get(self.selected_index).cloned()
611 };
612
613 self.matches.push_new_matches(
614 &self.history_items,
615 self.currently_opened_path.as_ref(),
616 Some(&query),
617 matches.into_iter(),
618 extend_old_matches,
619 );
620
621 self.selected_index = selected_match.map_or_else(
622 || self.calculate_selected_index(),
623 |m| {
624 self.matches
625 .position(&m, self.currently_opened_path.as_ref())
626 .unwrap_or(0)
627 },
628 );
629
630 self.latest_search_query = Some(query);
631 self.latest_search_did_cancel = did_cancel;
632
633 cx.notify();
634 }
635 }
636
637 fn labels_for_match(
638 &self,
639 path_match: &Match,
640 cx: &AppContext,
641 ix: usize,
642 ) -> (String, Vec<usize>, String, Vec<usize>) {
643 let (file_name, file_name_positions, full_path, full_path_positions) = match &path_match {
644 Match::History {
645 path: entry_path,
646 panel_match,
647 } => {
648 let worktree_id = entry_path.project.worktree_id;
649 let project_relative_path = &entry_path.project.path;
650 let has_worktree = self
651 .project
652 .read(cx)
653 .worktree_for_id(worktree_id, cx)
654 .is_some();
655
656 if !has_worktree {
657 if let Some(absolute_path) = &entry_path.absolute {
658 return (
659 absolute_path
660 .file_name()
661 .map_or_else(
662 || project_relative_path.to_string_lossy(),
663 |file_name| file_name.to_string_lossy(),
664 )
665 .to_string(),
666 Vec::new(),
667 absolute_path.to_string_lossy().to_string(),
668 Vec::new(),
669 );
670 }
671 }
672
673 let mut path = Arc::clone(project_relative_path);
674 if project_relative_path.as_ref() == Path::new("") {
675 if let Some(absolute_path) = &entry_path.absolute {
676 path = Arc::from(absolute_path.as_path());
677 }
678 }
679
680 let mut path_match = PathMatch {
681 score: ix as f64,
682 positions: Vec::new(),
683 worktree_id: worktree_id.to_usize(),
684 path,
685 is_dir: false, // File finder doesn't support directories
686 path_prefix: "".into(),
687 distance_to_relative_ancestor: usize::MAX,
688 };
689 if let Some(found_path_match) = &panel_match {
690 path_match
691 .positions
692 .extend(found_path_match.0.positions.iter())
693 }
694
695 self.labels_for_path_match(&path_match)
696 }
697 Match::Search(path_match) => self.labels_for_path_match(&path_match.0),
698 };
699
700 if file_name_positions.is_empty() {
701 if let Some(user_home_path) = std::env::var("HOME").ok() {
702 let user_home_path = user_home_path.trim();
703 if !user_home_path.is_empty() {
704 if (&full_path).starts_with(user_home_path) {
705 return (
706 file_name,
707 file_name_positions,
708 full_path.replace(user_home_path, "~"),
709 full_path_positions,
710 );
711 }
712 }
713 }
714 }
715
716 (
717 file_name,
718 file_name_positions,
719 full_path,
720 full_path_positions,
721 )
722 }
723
724 fn labels_for_path_match(
725 &self,
726 path_match: &PathMatch,
727 ) -> (String, Vec<usize>, String, Vec<usize>) {
728 let path = &path_match.path;
729 let path_string = path.to_string_lossy();
730 let full_path = [path_match.path_prefix.as_ref(), path_string.as_ref()].join("");
731 let mut path_positions = path_match.positions.clone();
732
733 let file_name = path.file_name().map_or_else(
734 || path_match.path_prefix.to_string(),
735 |file_name| file_name.to_string_lossy().to_string(),
736 );
737 let file_name_start = path_match.path_prefix.len() + path_string.len() - file_name.len();
738 let file_name_positions = path_positions
739 .iter()
740 .filter_map(|pos| {
741 if pos >= &file_name_start {
742 Some(pos - file_name_start)
743 } else {
744 None
745 }
746 })
747 .collect();
748
749 let full_path = full_path.trim_end_matches(&file_name).to_string();
750 path_positions.retain(|idx| *idx < full_path.len());
751
752 (file_name, file_name_positions, full_path, path_positions)
753 }
754
755 fn lookup_absolute_path(
756 &self,
757 query: FileSearchQuery,
758 cx: &mut ViewContext<'_, Picker<Self>>,
759 ) -> Task<()> {
760 cx.spawn(|picker, mut cx| async move {
761 let Some((project, fs)) = picker
762 .update(&mut cx, |picker, cx| {
763 let fs = Arc::clone(&picker.delegate.project.read(cx).fs());
764 (picker.delegate.project.clone(), fs)
765 })
766 .log_err()
767 else {
768 return;
769 };
770
771 let query_path = Path::new(query.path_query());
772 let mut path_matches = Vec::new();
773 match fs.metadata(query_path).await.log_err() {
774 Some(Some(_metadata)) => {
775 let update_result = project
776 .update(&mut cx, |project, cx| {
777 if let Some((worktree, relative_path)) =
778 project.find_worktree(query_path, cx)
779 {
780 path_matches.push(ProjectPanelOrdMatch(PathMatch {
781 score: 1.0,
782 positions: Vec::new(),
783 worktree_id: worktree.read(cx).id().to_usize(),
784 path: Arc::from(relative_path),
785 path_prefix: "".into(),
786 is_dir: false, // File finder doesn't support directories
787 distance_to_relative_ancestor: usize::MAX,
788 }));
789 }
790 })
791 .log_err();
792 if update_result.is_none() {
793 return;
794 }
795 }
796 Some(None) => {}
797 None => return,
798 }
799
800 picker
801 .update(&mut cx, |picker, cx| {
802 let picker_delegate = &mut picker.delegate;
803 let search_id = util::post_inc(&mut picker_delegate.search_count);
804 picker_delegate.set_search_matches(search_id, false, query, path_matches, cx);
805
806 anyhow::Ok(())
807 })
808 .log_err();
809 })
810 }
811
812 /// Skips first history match (that is displayed topmost) if it's currently opened.
813 fn calculate_selected_index(&self) -> usize {
814 if let Some(Match::History { path, .. }) = self.matches.get(0) {
815 if Some(path) == self.currently_opened_path.as_ref() {
816 let elements_after_first = self.matches.len() - 1;
817 if elements_after_first > 0 {
818 return 1;
819 }
820 }
821 }
822
823 0
824 }
825}
826
827impl PickerDelegate for FileFinderDelegate {
828 type ListItem = ListItem;
829
830 fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
831 "Search project files...".into()
832 }
833
834 fn match_count(&self) -> usize {
835 self.matches.len()
836 }
837
838 fn selected_index(&self) -> usize {
839 self.selected_index
840 }
841
842 fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Picker<Self>>) {
843 self.has_changed_selected_index = true;
844 self.selected_index = ix;
845 cx.notify();
846 }
847
848 fn separators_after_indices(&self) -> Vec<usize> {
849 if self.separate_history {
850 let first_non_history_index = self
851 .matches
852 .matches
853 .iter()
854 .enumerate()
855 .find(|(_, m)| !matches!(m, Match::History { .. }))
856 .map(|(i, _)| i);
857 if let Some(first_non_history_index) = first_non_history_index {
858 if first_non_history_index > 0 {
859 return vec![first_non_history_index - 1];
860 }
861 }
862 }
863 Vec::new()
864 }
865
866 fn update_matches(
867 &mut self,
868 raw_query: String,
869 cx: &mut ViewContext<Picker<Self>>,
870 ) -> Task<()> {
871 let raw_query = raw_query.replace(' ', "");
872 let raw_query = raw_query.trim();
873 if raw_query.is_empty() {
874 // if there was no query before, and we already have some (history) matches
875 // there's no need to update anything, since nothing has changed.
876 // We also want to populate matches set from history entries on the first update.
877 if self.latest_search_query.is_some() || self.first_update {
878 let project = self.project.read(cx);
879
880 self.latest_search_id = post_inc(&mut self.search_count);
881 self.latest_search_query = None;
882 self.matches = Matches {
883 separate_history: self.separate_history,
884 ..Matches::default()
885 };
886 self.matches.push_new_matches(
887 self.history_items.iter().filter(|history_item| {
888 project
889 .worktree_for_id(history_item.project.worktree_id, cx)
890 .is_some()
891 || (project.is_local() && history_item.absolute.is_some())
892 }),
893 self.currently_opened_path.as_ref(),
894 None,
895 None.into_iter(),
896 false,
897 );
898
899 self.first_update = false;
900 self.selected_index = 0;
901 }
902 cx.notify();
903 Task::ready(())
904 } else {
905 let path_position = PathWithPosition::parse_str(&raw_query);
906
907 let query = FileSearchQuery {
908 raw_query: raw_query.trim().to_owned(),
909 file_query_end: if path_position.path.to_str().unwrap_or(raw_query) == raw_query {
910 None
911 } else {
912 // Safe to unwrap as we won't get here when the unwrap in if fails
913 Some(path_position.path.to_str().unwrap().len())
914 },
915 path_position,
916 };
917
918 if Path::new(query.path_query()).is_absolute() {
919 self.lookup_absolute_path(query, cx)
920 } else {
921 self.spawn_search(query, cx)
922 }
923 }
924 }
925
926 fn confirm(&mut self, secondary: bool, cx: &mut ViewContext<Picker<FileFinderDelegate>>) {
927 if let Some(m) = self.matches.get(self.selected_index()) {
928 if let Some(workspace) = self.workspace.upgrade() {
929 let open_task = workspace.update(cx, move |workspace, cx| {
930 let split_or_open =
931 |workspace: &mut Workspace,
932 project_path,
933 cx: &mut ViewContext<Workspace>| {
934 let allow_preview =
935 PreviewTabsSettings::get_global(cx).enable_preview_from_file_finder;
936 if secondary {
937 workspace.split_path_preview(project_path, allow_preview, cx)
938 } else {
939 workspace.open_path_preview(
940 project_path,
941 None,
942 true,
943 allow_preview,
944 cx,
945 )
946 }
947 };
948 match &m {
949 Match::History { path, .. } => {
950 let worktree_id = path.project.worktree_id;
951 if workspace
952 .project()
953 .read(cx)
954 .worktree_for_id(worktree_id, cx)
955 .is_some()
956 {
957 split_or_open(
958 workspace,
959 ProjectPath {
960 worktree_id,
961 path: Arc::clone(&path.project.path),
962 },
963 cx,
964 )
965 } else {
966 match path.absolute.as_ref() {
967 Some(abs_path) => {
968 if secondary {
969 workspace.split_abs_path(
970 abs_path.to_path_buf(),
971 false,
972 cx,
973 )
974 } else {
975 workspace.open_abs_path(
976 abs_path.to_path_buf(),
977 false,
978 cx,
979 )
980 }
981 }
982 None => split_or_open(
983 workspace,
984 ProjectPath {
985 worktree_id,
986 path: Arc::clone(&path.project.path),
987 },
988 cx,
989 ),
990 }
991 }
992 }
993 Match::Search(m) => split_or_open(
994 workspace,
995 ProjectPath {
996 worktree_id: WorktreeId::from_usize(m.0.worktree_id),
997 path: m.0.path.clone(),
998 },
999 cx,
1000 ),
1001 }
1002 });
1003
1004 let row = self
1005 .latest_search_query
1006 .as_ref()
1007 .and_then(|query| query.path_position.row)
1008 .map(|row| row.saturating_sub(1));
1009 let col = self
1010 .latest_search_query
1011 .as_ref()
1012 .and_then(|query| query.path_position.column)
1013 .unwrap_or(0)
1014 .saturating_sub(1);
1015 let finder = self.file_finder.clone();
1016
1017 cx.spawn(|_, mut cx| async move {
1018 let item = open_task.await.notify_async_err(&mut cx)?;
1019 if let Some(row) = row {
1020 if let Some(active_editor) = item.downcast::<Editor>() {
1021 active_editor
1022 .downgrade()
1023 .update(&mut cx, |editor, cx| {
1024 let snapshot = editor.snapshot(cx).display_snapshot;
1025 let point = snapshot
1026 .buffer_snapshot
1027 .clip_point(Point::new(row, col), Bias::Left);
1028 editor.change_selections(Some(Autoscroll::center()), cx, |s| {
1029 s.select_ranges([point..point])
1030 });
1031 })
1032 .log_err();
1033 }
1034 }
1035 finder.update(&mut cx, |_, cx| cx.emit(DismissEvent)).ok()?;
1036
1037 Some(())
1038 })
1039 .detach();
1040 }
1041 }
1042 }
1043
1044 fn dismissed(&mut self, cx: &mut ViewContext<Picker<FileFinderDelegate>>) {
1045 self.file_finder
1046 .update(cx, |_, cx| cx.emit(DismissEvent))
1047 .log_err();
1048 }
1049
1050 fn render_match(
1051 &self,
1052 ix: usize,
1053 selected: bool,
1054 cx: &mut ViewContext<Picker<Self>>,
1055 ) -> Option<Self::ListItem> {
1056 let settings = FileFinderSettings::get_global(cx);
1057
1058 let path_match = self
1059 .matches
1060 .get(ix)
1061 .expect("Invalid matches state: no element for index {ix}");
1062
1063 let history_icon = match &path_match {
1064 Match::History { .. } => Icon::new(IconName::HistoryRerun)
1065 .color(Color::Muted)
1066 .size(IconSize::Small)
1067 .into_any_element(),
1068 Match::Search(_) => v_flex()
1069 .flex_none()
1070 .size(IconSize::Small.rems())
1071 .into_any_element(),
1072 };
1073 let (file_name, file_name_positions, full_path, full_path_positions) =
1074 self.labels_for_match(path_match, cx, ix);
1075
1076 let file_icon = if settings.file_icons {
1077 FileIcons::get_icon(Path::new(&file_name), cx)
1078 .map(Icon::from_path)
1079 .map(|icon| icon.color(Color::Muted))
1080 } else {
1081 None
1082 };
1083
1084 Some(
1085 ListItem::new(ix)
1086 .spacing(ListItemSpacing::Sparse)
1087 .start_slot::<Icon>(file_icon)
1088 .end_slot::<AnyElement>(history_icon)
1089 .inset(true)
1090 .selected(selected)
1091 .child(
1092 h_flex()
1093 .gap_2()
1094 .py_px()
1095 .child(HighlightedLabel::new(file_name, file_name_positions))
1096 .child(
1097 HighlightedLabel::new(full_path, full_path_positions)
1098 .size(LabelSize::Small)
1099 .color(Color::Muted),
1100 ),
1101 ),
1102 )
1103 }
1104}
1105
1106#[cfg(test)]
1107mod tests {
1108 use super::*;
1109
1110 #[test]
1111 fn test_custom_project_search_ordering_in_file_finder() {
1112 let mut file_finder_sorted_output = vec![
1113 ProjectPanelOrdMatch(PathMatch {
1114 score: 0.5,
1115 positions: Vec::new(),
1116 worktree_id: 0,
1117 path: Arc::from(Path::new("b0.5")),
1118 path_prefix: Arc::default(),
1119 distance_to_relative_ancestor: 0,
1120 is_dir: false,
1121 }),
1122 ProjectPanelOrdMatch(PathMatch {
1123 score: 1.0,
1124 positions: Vec::new(),
1125 worktree_id: 0,
1126 path: Arc::from(Path::new("c1.0")),
1127 path_prefix: Arc::default(),
1128 distance_to_relative_ancestor: 0,
1129 is_dir: false,
1130 }),
1131 ProjectPanelOrdMatch(PathMatch {
1132 score: 1.0,
1133 positions: Vec::new(),
1134 worktree_id: 0,
1135 path: Arc::from(Path::new("a1.0")),
1136 path_prefix: Arc::default(),
1137 distance_to_relative_ancestor: 0,
1138 is_dir: false,
1139 }),
1140 ProjectPanelOrdMatch(PathMatch {
1141 score: 0.5,
1142 positions: Vec::new(),
1143 worktree_id: 0,
1144 path: Arc::from(Path::new("a0.5")),
1145 path_prefix: Arc::default(),
1146 distance_to_relative_ancestor: 0,
1147 is_dir: false,
1148 }),
1149 ProjectPanelOrdMatch(PathMatch {
1150 score: 1.0,
1151 positions: Vec::new(),
1152 worktree_id: 0,
1153 path: Arc::from(Path::new("b1.0")),
1154 path_prefix: Arc::default(),
1155 distance_to_relative_ancestor: 0,
1156 is_dir: false,
1157 }),
1158 ];
1159 file_finder_sorted_output.sort_by(|a, b| b.cmp(a));
1160
1161 assert_eq!(
1162 file_finder_sorted_output,
1163 vec![
1164 ProjectPanelOrdMatch(PathMatch {
1165 score: 1.0,
1166 positions: Vec::new(),
1167 worktree_id: 0,
1168 path: Arc::from(Path::new("a1.0")),
1169 path_prefix: Arc::default(),
1170 distance_to_relative_ancestor: 0,
1171 is_dir: false,
1172 }),
1173 ProjectPanelOrdMatch(PathMatch {
1174 score: 1.0,
1175 positions: Vec::new(),
1176 worktree_id: 0,
1177 path: Arc::from(Path::new("b1.0")),
1178 path_prefix: Arc::default(),
1179 distance_to_relative_ancestor: 0,
1180 is_dir: false,
1181 }),
1182 ProjectPanelOrdMatch(PathMatch {
1183 score: 1.0,
1184 positions: Vec::new(),
1185 worktree_id: 0,
1186 path: Arc::from(Path::new("c1.0")),
1187 path_prefix: Arc::default(),
1188 distance_to_relative_ancestor: 0,
1189 is_dir: false,
1190 }),
1191 ProjectPanelOrdMatch(PathMatch {
1192 score: 0.5,
1193 positions: Vec::new(),
1194 worktree_id: 0,
1195 path: Arc::from(Path::new("a0.5")),
1196 path_prefix: Arc::default(),
1197 distance_to_relative_ancestor: 0,
1198 is_dir: false,
1199 }),
1200 ProjectPanelOrdMatch(PathMatch {
1201 score: 0.5,
1202 positions: Vec::new(),
1203 worktree_id: 0,
1204 path: Arc::from(Path::new("b0.5")),
1205 path_prefix: Arc::default(),
1206 distance_to_relative_ancestor: 0,
1207 is_dir: false,
1208 }),
1209 ]
1210 );
1211 }
1212}