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