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(Arc::clone(&found_path.project.path), 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 .map(|path_match| {
423 let (_, found_path) = candidates_paths
424 .remove_entry(&path_match.path)
425 .expect("candidate info not found");
426 (
427 Arc::clone(&path_match.path),
428 Match::History {
429 path: found_path.clone(),
430 panel_match: Some(ProjectPanelOrdMatch(path_match)),
431 },
432 )
433 }),
434 );
435 }
436 matching_history_paths
437}
438
439#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
440struct FoundPath {
441 project: ProjectPath,
442 absolute: Option<PathBuf>,
443}
444
445impl FoundPath {
446 fn new(project: ProjectPath, absolute: Option<PathBuf>) -> Self {
447 Self { project, absolute }
448 }
449}
450
451const MAX_RECENT_SELECTIONS: usize = 20;
452
453#[cfg(not(test))]
454fn history_file_exists(abs_path: &PathBuf) -> bool {
455 abs_path.exists()
456}
457
458#[cfg(test)]
459fn history_file_exists(abs_path: &Path) -> bool {
460 !abs_path.ends_with("nonexistent.rs")
461}
462
463pub enum Event {
464 Selected(ProjectPath),
465 Dismissed,
466}
467
468#[derive(Debug, Clone)]
469struct FileSearchQuery {
470 raw_query: String,
471 file_query_end: Option<usize>,
472 path_position: PathWithPosition,
473}
474
475impl FileSearchQuery {
476 fn path_query(&self) -> &str {
477 match self.file_query_end {
478 Some(file_path_end) => &self.raw_query[..file_path_end],
479 None => &self.raw_query,
480 }
481 }
482}
483
484impl FileFinderDelegate {
485 fn new(
486 file_finder: WeakView<FileFinder>,
487 workspace: WeakView<Workspace>,
488 project: Model<Project>,
489 currently_opened_path: Option<FoundPath>,
490 history_items: Vec<FoundPath>,
491 separate_history: bool,
492 cx: &mut ViewContext<FileFinder>,
493 ) -> Self {
494 Self::subscribe_to_updates(&project, cx);
495 Self {
496 file_finder,
497 workspace,
498 project,
499 search_count: 0,
500 latest_search_id: 0,
501 latest_search_did_cancel: false,
502 latest_search_query: None,
503 currently_opened_path,
504 matches: Matches::default(),
505 has_changed_selected_index: false,
506 selected_index: 0,
507 cancel_flag: Arc::new(AtomicBool::new(false)),
508 history_items,
509 separate_history,
510 first_update: true,
511 }
512 }
513
514 fn subscribe_to_updates(project: &Model<Project>, cx: &mut ViewContext<FileFinder>) {
515 cx.subscribe(project, |file_finder, _, event, cx| {
516 match event {
517 project::Event::WorktreeUpdatedEntries(_, _)
518 | project::Event::WorktreeAdded
519 | project::Event::WorktreeRemoved(_) => file_finder
520 .picker
521 .update(cx, |picker, cx| picker.refresh(cx)),
522 _ => {}
523 };
524 })
525 .detach();
526 }
527
528 fn spawn_search(
529 &mut self,
530 query: FileSearchQuery,
531 cx: &mut ViewContext<Picker<Self>>,
532 ) -> Task<()> {
533 let relative_to = self
534 .currently_opened_path
535 .as_ref()
536 .map(|found_path| Arc::clone(&found_path.project.path));
537 let worktrees = self
538 .project
539 .read(cx)
540 .visible_worktrees(cx)
541 .collect::<Vec<_>>();
542 let include_root_name = worktrees.len() > 1;
543 let candidate_sets = worktrees
544 .into_iter()
545 .map(|worktree| {
546 let worktree = worktree.read(cx);
547 PathMatchCandidateSet {
548 snapshot: worktree.snapshot(),
549 include_ignored: worktree
550 .root_entry()
551 .map_or(false, |entry| entry.is_ignored),
552 include_root_name,
553 candidates: project::Candidates::Files,
554 }
555 })
556 .collect::<Vec<_>>();
557
558 let search_id = util::post_inc(&mut self.search_count);
559 self.cancel_flag.store(true, atomic::Ordering::Relaxed);
560 self.cancel_flag = Arc::new(AtomicBool::new(false));
561 let cancel_flag = self.cancel_flag.clone();
562 cx.spawn(|picker, mut cx| async move {
563 let matches = fuzzy::match_path_sets(
564 candidate_sets.as_slice(),
565 query.path_query(),
566 relative_to,
567 false,
568 100,
569 &cancel_flag,
570 cx.background_executor().clone(),
571 )
572 .await
573 .into_iter()
574 .map(ProjectPanelOrdMatch);
575 let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed);
576 picker
577 .update(&mut cx, |picker, cx| {
578 picker
579 .delegate
580 .set_search_matches(search_id, did_cancel, query, matches, cx)
581 })
582 .log_err();
583 })
584 }
585
586 fn set_search_matches(
587 &mut self,
588 search_id: usize,
589 did_cancel: bool,
590 query: FileSearchQuery,
591 matches: impl IntoIterator<Item = ProjectPanelOrdMatch>,
592 cx: &mut ViewContext<Picker<Self>>,
593 ) {
594 if search_id >= self.latest_search_id {
595 self.latest_search_id = search_id;
596 let query_changed = Some(query.path_query())
597 != self
598 .latest_search_query
599 .as_ref()
600 .map(|query| query.path_query());
601 let extend_old_matches = self.latest_search_did_cancel && !query_changed;
602
603 let selected_match = if query_changed {
604 None
605 } else {
606 self.matches.get(self.selected_index).cloned()
607 };
608
609 self.matches.push_new_matches(
610 &self.history_items,
611 self.currently_opened_path.as_ref(),
612 Some(&query),
613 matches.into_iter(),
614 extend_old_matches,
615 );
616
617 self.selected_index = selected_match.map_or_else(
618 || self.calculate_selected_index(),
619 |m| {
620 self.matches
621 .position(&m, self.currently_opened_path.as_ref())
622 .unwrap_or(0)
623 },
624 );
625
626 self.latest_search_query = Some(query);
627 self.latest_search_did_cancel = did_cancel;
628
629 cx.notify();
630 }
631 }
632
633 fn labels_for_match(
634 &self,
635 path_match: &Match,
636 cx: &AppContext,
637 ix: usize,
638 ) -> (String, Vec<usize>, String, Vec<usize>) {
639 let (file_name, file_name_positions, full_path, full_path_positions) = match &path_match {
640 Match::History {
641 path: entry_path,
642 panel_match,
643 } => {
644 let worktree_id = entry_path.project.worktree_id;
645 let project_relative_path = &entry_path.project.path;
646 let has_worktree = self
647 .project
648 .read(cx)
649 .worktree_for_id(worktree_id, cx)
650 .is_some();
651
652 if !has_worktree {
653 if let Some(absolute_path) = &entry_path.absolute {
654 return (
655 absolute_path
656 .file_name()
657 .map_or_else(
658 || project_relative_path.to_string_lossy(),
659 |file_name| file_name.to_string_lossy(),
660 )
661 .to_string(),
662 Vec::new(),
663 absolute_path.to_string_lossy().to_string(),
664 Vec::new(),
665 );
666 }
667 }
668
669 let mut path = Arc::clone(project_relative_path);
670 if project_relative_path.as_ref() == Path::new("") {
671 if let Some(absolute_path) = &entry_path.absolute {
672 path = Arc::from(absolute_path.as_path());
673 }
674 }
675
676 let mut path_match = PathMatch {
677 score: ix as f64,
678 positions: Vec::new(),
679 worktree_id: worktree_id.to_usize(),
680 path,
681 is_dir: false, // File finder doesn't support directories
682 path_prefix: "".into(),
683 distance_to_relative_ancestor: usize::MAX,
684 };
685 if let Some(found_path_match) = &panel_match {
686 path_match
687 .positions
688 .extend(found_path_match.0.positions.iter())
689 }
690
691 self.labels_for_path_match(&path_match)
692 }
693 Match::Search(path_match) => self.labels_for_path_match(&path_match.0),
694 };
695
696 if file_name_positions.is_empty() {
697 if let Some(user_home_path) = std::env::var("HOME").ok() {
698 let user_home_path = user_home_path.trim();
699 if !user_home_path.is_empty() {
700 if (&full_path).starts_with(user_home_path) {
701 return (
702 file_name,
703 file_name_positions,
704 full_path.replace(user_home_path, "~"),
705 full_path_positions,
706 );
707 }
708 }
709 }
710 }
711
712 (
713 file_name,
714 file_name_positions,
715 full_path,
716 full_path_positions,
717 )
718 }
719
720 fn labels_for_path_match(
721 &self,
722 path_match: &PathMatch,
723 ) -> (String, Vec<usize>, String, Vec<usize>) {
724 let path = &path_match.path;
725 let path_string = path.to_string_lossy();
726 let full_path = [path_match.path_prefix.as_ref(), path_string.as_ref()].join("");
727 let mut path_positions = path_match.positions.clone();
728
729 let file_name = path.file_name().map_or_else(
730 || path_match.path_prefix.to_string(),
731 |file_name| file_name.to_string_lossy().to_string(),
732 );
733 let file_name_start = path_match.path_prefix.len() + path_string.len() - file_name.len();
734 let file_name_positions = path_positions
735 .iter()
736 .filter_map(|pos| {
737 if pos >= &file_name_start {
738 Some(pos - file_name_start)
739 } else {
740 None
741 }
742 })
743 .collect();
744
745 let full_path = full_path.trim_end_matches(&file_name).to_string();
746 path_positions.retain(|idx| *idx < full_path.len());
747
748 (file_name, file_name_positions, full_path, path_positions)
749 }
750
751 fn lookup_absolute_path(
752 &self,
753 query: FileSearchQuery,
754 cx: &mut ViewContext<'_, Picker<Self>>,
755 ) -> Task<()> {
756 cx.spawn(|picker, mut cx| async move {
757 let Some((project, fs)) = picker
758 .update(&mut cx, |picker, cx| {
759 let fs = Arc::clone(&picker.delegate.project.read(cx).fs());
760 (picker.delegate.project.clone(), fs)
761 })
762 .log_err()
763 else {
764 return;
765 };
766
767 let query_path = Path::new(query.path_query());
768 let mut path_matches = Vec::new();
769 match fs.metadata(query_path).await.log_err() {
770 Some(Some(_metadata)) => {
771 let update_result = project
772 .update(&mut cx, |project, cx| {
773 if let Some((worktree, relative_path)) =
774 project.find_worktree(query_path, cx)
775 {
776 path_matches.push(ProjectPanelOrdMatch(PathMatch {
777 score: 1.0,
778 positions: Vec::new(),
779 worktree_id: worktree.read(cx).id().to_usize(),
780 path: Arc::from(relative_path),
781 path_prefix: "".into(),
782 is_dir: false, // File finder doesn't support directories
783 distance_to_relative_ancestor: usize::MAX,
784 }));
785 }
786 })
787 .log_err();
788 if update_result.is_none() {
789 return;
790 }
791 }
792 Some(None) => {}
793 None => return,
794 }
795
796 picker
797 .update(&mut cx, |picker, cx| {
798 let picker_delegate = &mut picker.delegate;
799 let search_id = util::post_inc(&mut picker_delegate.search_count);
800 picker_delegate.set_search_matches(search_id, false, query, path_matches, cx);
801
802 anyhow::Ok(())
803 })
804 .log_err();
805 })
806 }
807
808 /// Skips first history match (that is displayed topmost) if it's currently opened.
809 fn calculate_selected_index(&self) -> usize {
810 if let Some(Match::History { path, .. }) = self.matches.get(0) {
811 if Some(path) == self.currently_opened_path.as_ref() {
812 let elements_after_first = self.matches.len() - 1;
813 if elements_after_first > 0 {
814 return 1;
815 }
816 }
817 }
818
819 0
820 }
821}
822
823impl PickerDelegate for FileFinderDelegate {
824 type ListItem = ListItem;
825
826 fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
827 "Search project files...".into()
828 }
829
830 fn match_count(&self) -> usize {
831 self.matches.len()
832 }
833
834 fn selected_index(&self) -> usize {
835 self.selected_index
836 }
837
838 fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Picker<Self>>) {
839 self.has_changed_selected_index = true;
840 self.selected_index = ix;
841 cx.notify();
842 }
843
844 fn separators_after_indices(&self) -> Vec<usize> {
845 if self.separate_history {
846 let first_non_history_index = self
847 .matches
848 .matches
849 .iter()
850 .enumerate()
851 .find(|(_, m)| !matches!(m, Match::History { .. }))
852 .map(|(i, _)| i);
853 if let Some(first_non_history_index) = first_non_history_index {
854 if first_non_history_index > 0 {
855 return vec![first_non_history_index - 1];
856 }
857 }
858 }
859 Vec::new()
860 }
861
862 fn update_matches(
863 &mut self,
864 raw_query: String,
865 cx: &mut ViewContext<Picker<Self>>,
866 ) -> Task<()> {
867 let raw_query = raw_query.replace(' ', "");
868 let raw_query = raw_query.trim();
869 if raw_query.is_empty() {
870 // if there was no query before, and we already have some (history) matches
871 // there's no need to update anything, since nothing has changed.
872 // We also want to populate matches set from history entries on the first update.
873 if self.latest_search_query.is_some() || self.first_update {
874 let project = self.project.read(cx);
875
876 self.latest_search_id = post_inc(&mut self.search_count);
877 self.latest_search_query = None;
878 self.matches = Matches {
879 separate_history: self.separate_history,
880 ..Matches::default()
881 };
882 self.matches.push_new_matches(
883 self.history_items.iter().filter(|history_item| {
884 project
885 .worktree_for_id(history_item.project.worktree_id, cx)
886 .is_some()
887 || (project.is_local() && history_item.absolute.is_some())
888 }),
889 self.currently_opened_path.as_ref(),
890 None,
891 None.into_iter(),
892 false,
893 );
894
895 self.first_update = false;
896 self.selected_index = 0;
897 }
898 cx.notify();
899 Task::ready(())
900 } else {
901 let path_position = PathWithPosition::parse_str(&raw_query);
902
903 let query = FileSearchQuery {
904 raw_query: raw_query.trim().to_owned(),
905 file_query_end: if path_position.path.to_str().unwrap_or(raw_query) == raw_query {
906 None
907 } else {
908 // Safe to unwrap as we won't get here when the unwrap in if fails
909 Some(path_position.path.to_str().unwrap().len())
910 },
911 path_position,
912 };
913
914 if Path::new(query.path_query()).is_absolute() {
915 self.lookup_absolute_path(query, cx)
916 } else {
917 self.spawn_search(query, cx)
918 }
919 }
920 }
921
922 fn confirm(&mut self, secondary: bool, cx: &mut ViewContext<Picker<FileFinderDelegate>>) {
923 if let Some(m) = self.matches.get(self.selected_index()) {
924 if let Some(workspace) = self.workspace.upgrade() {
925 let open_task = workspace.update(cx, move |workspace, cx| {
926 let split_or_open =
927 |workspace: &mut Workspace,
928 project_path,
929 cx: &mut ViewContext<Workspace>| {
930 let allow_preview =
931 PreviewTabsSettings::get_global(cx).enable_preview_from_file_finder;
932 if secondary {
933 workspace.split_path_preview(project_path, allow_preview, cx)
934 } else {
935 workspace.open_path_preview(
936 project_path,
937 None,
938 true,
939 allow_preview,
940 cx,
941 )
942 }
943 };
944 match &m {
945 Match::History { path, .. } => {
946 let worktree_id = path.project.worktree_id;
947 if workspace
948 .project()
949 .read(cx)
950 .worktree_for_id(worktree_id, cx)
951 .is_some()
952 {
953 split_or_open(
954 workspace,
955 ProjectPath {
956 worktree_id,
957 path: Arc::clone(&path.project.path),
958 },
959 cx,
960 )
961 } else {
962 match path.absolute.as_ref() {
963 Some(abs_path) => {
964 if secondary {
965 workspace.split_abs_path(
966 abs_path.to_path_buf(),
967 false,
968 cx,
969 )
970 } else {
971 workspace.open_abs_path(
972 abs_path.to_path_buf(),
973 false,
974 cx,
975 )
976 }
977 }
978 None => split_or_open(
979 workspace,
980 ProjectPath {
981 worktree_id,
982 path: Arc::clone(&path.project.path),
983 },
984 cx,
985 ),
986 }
987 }
988 }
989 Match::Search(m) => split_or_open(
990 workspace,
991 ProjectPath {
992 worktree_id: WorktreeId::from_usize(m.0.worktree_id),
993 path: m.0.path.clone(),
994 },
995 cx,
996 ),
997 }
998 });
999
1000 let row = self
1001 .latest_search_query
1002 .as_ref()
1003 .and_then(|query| query.path_position.row)
1004 .map(|row| row.saturating_sub(1));
1005 let col = self
1006 .latest_search_query
1007 .as_ref()
1008 .and_then(|query| query.path_position.column)
1009 .unwrap_or(0)
1010 .saturating_sub(1);
1011 let finder = self.file_finder.clone();
1012
1013 cx.spawn(|_, mut cx| async move {
1014 let item = open_task.await.notify_async_err(&mut cx)?;
1015 if let Some(row) = row {
1016 if let Some(active_editor) = item.downcast::<Editor>() {
1017 active_editor
1018 .downgrade()
1019 .update(&mut cx, |editor, cx| {
1020 let snapshot = editor.snapshot(cx).display_snapshot;
1021 let point = snapshot
1022 .buffer_snapshot
1023 .clip_point(Point::new(row, col), Bias::Left);
1024 editor.change_selections(Some(Autoscroll::center()), cx, |s| {
1025 s.select_ranges([point..point])
1026 });
1027 })
1028 .log_err();
1029 }
1030 }
1031 finder.update(&mut cx, |_, cx| cx.emit(DismissEvent)).ok()?;
1032
1033 Some(())
1034 })
1035 .detach();
1036 }
1037 }
1038 }
1039
1040 fn dismissed(&mut self, cx: &mut ViewContext<Picker<FileFinderDelegate>>) {
1041 self.file_finder
1042 .update(cx, |_, cx| cx.emit(DismissEvent))
1043 .log_err();
1044 }
1045
1046 fn render_match(
1047 &self,
1048 ix: usize,
1049 selected: bool,
1050 cx: &mut ViewContext<Picker<Self>>,
1051 ) -> Option<Self::ListItem> {
1052 let settings = FileFinderSettings::get_global(cx);
1053
1054 let path_match = self
1055 .matches
1056 .get(ix)
1057 .expect("Invalid matches state: no element for index {ix}");
1058
1059 let history_icon = match &path_match {
1060 Match::History { .. } => Icon::new(IconName::HistoryRerun)
1061 .color(Color::Muted)
1062 .size(IconSize::Small)
1063 .into_any_element(),
1064 Match::Search(_) => v_flex()
1065 .flex_none()
1066 .size(IconSize::Small.rems())
1067 .into_any_element(),
1068 };
1069 let (file_name, file_name_positions, full_path, full_path_positions) =
1070 self.labels_for_match(path_match, cx, ix);
1071
1072 let file_icon = if settings.file_icons {
1073 FileIcons::get_icon(Path::new(&file_name), cx)
1074 .map(Icon::from_path)
1075 .map(|icon| icon.color(Color::Muted))
1076 } else {
1077 None
1078 };
1079
1080 Some(
1081 ListItem::new(ix)
1082 .spacing(ListItemSpacing::Sparse)
1083 .start_slot::<Icon>(file_icon)
1084 .end_slot::<AnyElement>(history_icon)
1085 .inset(true)
1086 .selected(selected)
1087 .child(
1088 h_flex()
1089 .gap_2()
1090 .py_px()
1091 .child(HighlightedLabel::new(file_name, file_name_positions))
1092 .child(
1093 HighlightedLabel::new(full_path, full_path_positions)
1094 .size(LabelSize::Small)
1095 .color(Color::Muted),
1096 ),
1097 ),
1098 )
1099 }
1100}
1101
1102#[cfg(test)]
1103mod tests {
1104 use super::*;
1105
1106 #[test]
1107 fn test_custom_project_search_ordering_in_file_finder() {
1108 let mut file_finder_sorted_output = vec![
1109 ProjectPanelOrdMatch(PathMatch {
1110 score: 0.5,
1111 positions: Vec::new(),
1112 worktree_id: 0,
1113 path: Arc::from(Path::new("b0.5")),
1114 path_prefix: Arc::default(),
1115 distance_to_relative_ancestor: 0,
1116 is_dir: false,
1117 }),
1118 ProjectPanelOrdMatch(PathMatch {
1119 score: 1.0,
1120 positions: Vec::new(),
1121 worktree_id: 0,
1122 path: Arc::from(Path::new("c1.0")),
1123 path_prefix: Arc::default(),
1124 distance_to_relative_ancestor: 0,
1125 is_dir: false,
1126 }),
1127 ProjectPanelOrdMatch(PathMatch {
1128 score: 1.0,
1129 positions: Vec::new(),
1130 worktree_id: 0,
1131 path: Arc::from(Path::new("a1.0")),
1132 path_prefix: Arc::default(),
1133 distance_to_relative_ancestor: 0,
1134 is_dir: false,
1135 }),
1136 ProjectPanelOrdMatch(PathMatch {
1137 score: 0.5,
1138 positions: Vec::new(),
1139 worktree_id: 0,
1140 path: Arc::from(Path::new("a0.5")),
1141 path_prefix: Arc::default(),
1142 distance_to_relative_ancestor: 0,
1143 is_dir: false,
1144 }),
1145 ProjectPanelOrdMatch(PathMatch {
1146 score: 1.0,
1147 positions: Vec::new(),
1148 worktree_id: 0,
1149 path: Arc::from(Path::new("b1.0")),
1150 path_prefix: Arc::default(),
1151 distance_to_relative_ancestor: 0,
1152 is_dir: false,
1153 }),
1154 ];
1155 file_finder_sorted_output.sort_by(|a, b| b.cmp(a));
1156
1157 assert_eq!(
1158 file_finder_sorted_output,
1159 vec![
1160 ProjectPanelOrdMatch(PathMatch {
1161 score: 1.0,
1162 positions: Vec::new(),
1163 worktree_id: 0,
1164 path: Arc::from(Path::new("a1.0")),
1165 path_prefix: Arc::default(),
1166 distance_to_relative_ancestor: 0,
1167 is_dir: false,
1168 }),
1169 ProjectPanelOrdMatch(PathMatch {
1170 score: 1.0,
1171 positions: Vec::new(),
1172 worktree_id: 0,
1173 path: Arc::from(Path::new("b1.0")),
1174 path_prefix: Arc::default(),
1175 distance_to_relative_ancestor: 0,
1176 is_dir: false,
1177 }),
1178 ProjectPanelOrdMatch(PathMatch {
1179 score: 1.0,
1180 positions: Vec::new(),
1181 worktree_id: 0,
1182 path: Arc::from(Path::new("c1.0")),
1183 path_prefix: Arc::default(),
1184 distance_to_relative_ancestor: 0,
1185 is_dir: false,
1186 }),
1187 ProjectPanelOrdMatch(PathMatch {
1188 score: 0.5,
1189 positions: Vec::new(),
1190 worktree_id: 0,
1191 path: Arc::from(Path::new("a0.5")),
1192 path_prefix: Arc::default(),
1193 distance_to_relative_ancestor: 0,
1194 is_dir: false,
1195 }),
1196 ProjectPanelOrdMatch(PathMatch {
1197 score: 0.5,
1198 positions: Vec::new(),
1199 worktree_id: 0,
1200 path: Arc::from(Path::new("b0.5")),
1201 path_prefix: Arc::default(),
1202 distance_to_relative_ancestor: 0,
1203 is_dir: false,
1204 }),
1205 ]
1206 );
1207 }
1208}