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