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