1#[cfg(test)]
2mod file_finder_tests;
3
4use futures::future::join_all;
5pub use open_path_prompt::OpenPathDelegate;
6
7use channel::ChannelStore;
8use client::ChannelId;
9use collections::HashMap;
10use editor::Editor;
11use file_icons::FileIcons;
12use fuzzy::{StringMatch, StringMatchCandidate};
13use fuzzy_nucleo::{PathMatch, PathMatchCandidate};
14use gpui::{
15 Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
16 KeyContext, Modifiers, ModifiersChangedEvent, ParentElement, Render,
17 StatefulInteractiveElement, Styled, Task, WeakEntity, Window, actions, rems,
18};
19use open_path_prompt::{
20 OpenPathPrompt,
21 file_finder_settings::{FileFinderSettings, FileFinderWidth},
22};
23use picker::{Picker, PickerDelegate};
24use project::{
25 PathMatchCandidateSet, Project, ProjectPath, WorktreeId, worktree_store::WorktreeStore,
26};
27use project_panel::project_panel_settings::ProjectPanelSettings;
28use settings::Settings;
29use std::{
30 borrow::Cow,
31 cmp,
32 ops::Range,
33 path::{Component, Path, PathBuf},
34 sync::{
35 Arc,
36 atomic::{self, AtomicBool},
37 },
38};
39use ui::{
40 ButtonLike, CommonAnimationExt, ContextMenu, HighlightedLabel, Indicator, KeyBinding, ListItem,
41 ListItemSpacing, PopoverMenu, PopoverMenuHandle, TintColor, Tooltip, prelude::*,
42};
43use ui_input::ErasedEditor;
44use util::{
45 ResultExt, maybe,
46 paths::{PathStyle, PathWithPosition},
47 post_inc,
48 rel_path::RelPath,
49};
50use workspace::{
51 ModalView, OpenChannelNotesById, OpenOptions, OpenVisible, SplitDirection, Workspace,
52 item::PreviewTabsSettings, notifications::NotifyResultExt, pane,
53};
54use zed_actions::search::ToggleIncludeIgnored;
55
56actions!(
57 file_finder,
58 [
59 /// Selects the previous item in the file finder.
60 SelectPrevious,
61 /// Toggles the file filter menu.
62 ToggleFilterMenu,
63 /// Toggles the split direction menu.
64 ToggleSplitMenu
65 ]
66);
67
68impl ModalView for FileFinder {
69 fn on_before_dismiss(
70 &mut self,
71 window: &mut Window,
72 cx: &mut Context<Self>,
73 ) -> workspace::DismissDecision {
74 let submenu_focused = self.picker.update(cx, |picker, cx| {
75 picker
76 .delegate
77 .filter_popover_menu_handle
78 .is_focused(window, cx)
79 || picker
80 .delegate
81 .split_popover_menu_handle
82 .is_focused(window, cx)
83 });
84 workspace::DismissDecision::Dismiss(!submenu_focused)
85 }
86}
87
88pub struct FileFinder {
89 picker: Entity<Picker<FileFinderDelegate>>,
90 picker_focus_handle: FocusHandle,
91 init_modifiers: Option<Modifiers>,
92}
93
94pub fn init(cx: &mut App) {
95 cx.observe_new(FileFinder::register).detach();
96 cx.observe_new(OpenPathPrompt::register).detach();
97 cx.observe_new(OpenPathPrompt::register_new_path).detach();
98}
99
100impl FileFinder {
101 fn register(
102 workspace: &mut Workspace,
103 _window: Option<&mut Window>,
104 _: &mut Context<Workspace>,
105 ) {
106 workspace.register_action(
107 |workspace, action: &workspace::ToggleFileFinder, window, cx| {
108 let Some(file_finder) = workspace.active_modal::<Self>(cx) else {
109 Self::open(workspace, action.separate_history, window, cx).detach();
110 return;
111 };
112
113 file_finder.update(cx, |file_finder, cx| {
114 file_finder.init_modifiers = Some(window.modifiers());
115 file_finder.picker.update(cx, |picker, cx| {
116 picker.cycle_selection(window, cx);
117 });
118 });
119 },
120 );
121 }
122
123 fn open(
124 workspace: &mut Workspace,
125 separate_history: bool,
126 window: &mut Window,
127 cx: &mut Context<Workspace>,
128 ) -> Task<()> {
129 let project = workspace.project().read(cx);
130 let fs = project.fs();
131
132 let currently_opened_path = workspace.active_item(cx).and_then(|item| {
133 let project_path = item.project_path(cx)?;
134 let abs_path = project
135 .worktree_for_id(project_path.worktree_id, cx)?
136 .read(cx)
137 .absolutize(&project_path.path);
138 Some(FoundPath::new(project_path, abs_path))
139 });
140
141 let history_items = workspace
142 .recent_navigation_history(Some(MAX_RECENT_SELECTIONS), cx)
143 .into_iter()
144 .filter_map(|(project_path, abs_path)| {
145 if project.entry_for_path(&project_path, cx).is_some() {
146 return Some(Task::ready(Some(FoundPath::new(project_path, abs_path?))));
147 }
148 let abs_path = abs_path?;
149 if project.is_local() {
150 let fs = fs.clone();
151 Some(cx.background_spawn(async move {
152 if fs.is_file(&abs_path).await {
153 Some(FoundPath::new(project_path, abs_path))
154 } else {
155 None
156 }
157 }))
158 } else {
159 Some(Task::ready(Some(FoundPath::new(project_path, abs_path))))
160 }
161 })
162 .collect::<Vec<_>>();
163 cx.spawn_in(window, async move |workspace, cx| {
164 let history_items = join_all(history_items).await.into_iter().flatten();
165
166 workspace
167 .update_in(cx, |workspace, window, cx| {
168 let project = workspace.project().clone();
169 let weak_workspace = cx.entity().downgrade();
170 workspace.toggle_modal(window, cx, |window, cx| {
171 let delegate = FileFinderDelegate::new(
172 cx.entity().downgrade(),
173 weak_workspace,
174 project,
175 currently_opened_path,
176 history_items.collect(),
177 separate_history,
178 window,
179 cx,
180 );
181
182 FileFinder::new(delegate, window, cx)
183 });
184 })
185 .ok();
186 })
187 }
188
189 fn new(delegate: FileFinderDelegate, window: &mut Window, cx: &mut Context<Self>) -> Self {
190 let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
191 let picker_focus_handle = picker.focus_handle(cx);
192 picker.update(cx, |picker, _| {
193 picker.delegate.focus_handle = picker_focus_handle.clone();
194 });
195 Self {
196 picker,
197 picker_focus_handle,
198 init_modifiers: window.modifiers().modified().then_some(window.modifiers()),
199 }
200 }
201
202 fn handle_modifiers_changed(
203 &mut self,
204 event: &ModifiersChangedEvent,
205 window: &mut Window,
206 cx: &mut Context<Self>,
207 ) {
208 let Some(init_modifiers) = self.init_modifiers.take() else {
209 return;
210 };
211 if self.picker.read(cx).delegate.has_changed_selected_index
212 && (!event.modified() || !init_modifiers.is_subset_of(event))
213 {
214 self.init_modifiers = None;
215 window.dispatch_action(menu::Confirm.boxed_clone(), cx);
216 }
217 }
218
219 fn handle_select_prev(
220 &mut self,
221 _: &SelectPrevious,
222 window: &mut Window,
223 cx: &mut Context<Self>,
224 ) {
225 self.init_modifiers = Some(window.modifiers());
226 window.dispatch_action(Box::new(menu::SelectPrevious), cx);
227 }
228
229 fn handle_filter_toggle_menu(
230 &mut self,
231 _: &ToggleFilterMenu,
232 window: &mut Window,
233 cx: &mut Context<Self>,
234 ) {
235 self.picker.update(cx, |picker, cx| {
236 let menu_handle = &picker.delegate.filter_popover_menu_handle;
237 if menu_handle.is_deployed() {
238 menu_handle.hide(cx);
239 } else {
240 menu_handle.show(window, cx);
241 }
242 });
243 }
244
245 fn handle_split_toggle_menu(
246 &mut self,
247 _: &ToggleSplitMenu,
248 window: &mut Window,
249 cx: &mut Context<Self>,
250 ) {
251 self.picker.update(cx, |picker, cx| {
252 let menu_handle = &picker.delegate.split_popover_menu_handle;
253 if menu_handle.is_deployed() {
254 menu_handle.hide(cx);
255 } else {
256 menu_handle.show(window, cx);
257 }
258 });
259 }
260
261 fn handle_toggle_ignored(
262 &mut self,
263 _: &ToggleIncludeIgnored,
264 window: &mut Window,
265 cx: &mut Context<Self>,
266 ) {
267 self.picker.update(cx, |picker, cx| {
268 picker.delegate.include_ignored = match picker.delegate.include_ignored {
269 Some(true) => FileFinderSettings::get_global(cx)
270 .include_ignored
271 .map(|_| false),
272 Some(false) => Some(true),
273 None => Some(true),
274 };
275 picker.delegate.include_ignored_refresh =
276 picker.delegate.update_matches(picker.query(cx), window, cx);
277 });
278 }
279
280 fn go_to_file_split_left(
281 &mut self,
282 _: &pane::SplitLeft,
283 window: &mut Window,
284 cx: &mut Context<Self>,
285 ) {
286 self.go_to_file_split_inner(SplitDirection::Left, window, cx)
287 }
288
289 fn go_to_file_split_right(
290 &mut self,
291 _: &pane::SplitRight,
292 window: &mut Window,
293 cx: &mut Context<Self>,
294 ) {
295 self.go_to_file_split_inner(SplitDirection::Right, window, cx)
296 }
297
298 fn go_to_file_split_up(
299 &mut self,
300 _: &pane::SplitUp,
301 window: &mut Window,
302 cx: &mut Context<Self>,
303 ) {
304 self.go_to_file_split_inner(SplitDirection::Up, window, cx)
305 }
306
307 fn go_to_file_split_down(
308 &mut self,
309 _: &pane::SplitDown,
310 window: &mut Window,
311 cx: &mut Context<Self>,
312 ) {
313 self.go_to_file_split_inner(SplitDirection::Down, window, cx)
314 }
315
316 fn go_to_file_split_inner(
317 &mut self,
318 split_direction: SplitDirection,
319 window: &mut Window,
320 cx: &mut Context<Self>,
321 ) {
322 self.picker.update(cx, |picker, cx| {
323 let delegate = &mut picker.delegate;
324 if let Some(workspace) = delegate.workspace.upgrade()
325 && let Some(m) = delegate.matches.get(delegate.selected_index())
326 {
327 let path = match m {
328 Match::History { path, .. } => {
329 let worktree_id = path.project.worktree_id;
330 ProjectPath {
331 worktree_id,
332 path: Arc::clone(&path.project.path),
333 }
334 }
335 Match::Search(m) => ProjectPath {
336 worktree_id: WorktreeId::from_usize(m.0.worktree_id),
337 path: m.0.path.clone(),
338 },
339 Match::CreateNew(p) => p.clone(),
340 Match::Channel { .. } => return,
341 };
342 let open_task = workspace.update(cx, move |workspace, cx| {
343 workspace.split_path_preview(path, false, Some(split_direction), window, cx)
344 });
345 open_task.detach_and_log_err(cx);
346 }
347 })
348 }
349
350 pub fn modal_max_width(width_setting: FileFinderWidth, window: &mut Window) -> Pixels {
351 let window_width = window.viewport_size().width;
352 let small_width = rems(34.).to_pixels(window.rem_size());
353
354 match width_setting {
355 FileFinderWidth::Small => small_width,
356 FileFinderWidth::Full => window_width,
357 FileFinderWidth::XLarge => (window_width - px(512.)).max(small_width),
358 FileFinderWidth::Large => (window_width - px(768.)).max(small_width),
359 FileFinderWidth::Medium => (window_width - px(1024.)).max(small_width),
360 }
361 }
362}
363
364impl EventEmitter<DismissEvent> for FileFinder {}
365
366impl Focusable for FileFinder {
367 fn focus_handle(&self, _: &App) -> FocusHandle {
368 self.picker_focus_handle.clone()
369 }
370}
371
372impl Render for FileFinder {
373 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
374 let key_context = self.picker.read(cx).delegate.key_context(window, cx);
375
376 let file_finder_settings = FileFinderSettings::get_global(cx);
377 let modal_max_width = Self::modal_max_width(file_finder_settings.modal_max_width, window);
378
379 v_flex()
380 .key_context(key_context)
381 .w(modal_max_width)
382 .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
383 .on_action(cx.listener(Self::handle_select_prev))
384 .on_action(cx.listener(Self::handle_filter_toggle_menu))
385 .on_action(cx.listener(Self::handle_split_toggle_menu))
386 .on_action(cx.listener(Self::handle_toggle_ignored))
387 .on_action(cx.listener(Self::go_to_file_split_left))
388 .on_action(cx.listener(Self::go_to_file_split_right))
389 .on_action(cx.listener(Self::go_to_file_split_up))
390 .on_action(cx.listener(Self::go_to_file_split_down))
391 .child(self.picker.clone())
392 }
393}
394
395pub struct FileFinderDelegate {
396 file_finder: WeakEntity<FileFinder>,
397 workspace: WeakEntity<Workspace>,
398 project: Entity<Project>,
399 channel_store: Option<Entity<ChannelStore>>,
400 search_count: usize,
401 latest_search_id: usize,
402 latest_search_did_cancel: bool,
403 latest_search_query: Option<FileSearchQuery>,
404 currently_opened_path: Option<FoundPath>,
405 matches: Matches,
406 selected_index: usize,
407 has_changed_selected_index: bool,
408 cancel_flag: Arc<AtomicBool>,
409 history_items: Vec<FoundPath>,
410 separate_history: bool,
411 first_update: bool,
412 filter_popover_menu_handle: PopoverMenuHandle<ContextMenu>,
413 split_popover_menu_handle: PopoverMenuHandle<ContextMenu>,
414 focus_handle: FocusHandle,
415 include_ignored: Option<bool>,
416 include_ignored_refresh: Task<()>,
417}
418
419/// Use a custom ordering for file finder: the regular one
420/// defines max element with the highest score and the latest alphanumerical path (in case of a tie on other params), e.g:
421/// `[{score: 0.5, path = "c/d" }, { score: 0.5, path = "/a/b" }]`
422///
423/// In the file finder, we would prefer to have the max element with the highest score and the earliest alphanumerical path, e.g:
424/// `[{ score: 0.5, path = "/a/b" }, {score: 0.5, path = "c/d" }]`
425/// as the files are shown in the project panel lists.
426#[derive(Debug, Clone, PartialEq, Eq)]
427struct ProjectPanelOrdMatch(PathMatch);
428
429impl Ord for ProjectPanelOrdMatch {
430 fn cmp(&self, other: &Self) -> cmp::Ordering {
431 self.0
432 .score
433 .partial_cmp(&other.0.score)
434 .unwrap_or(cmp::Ordering::Equal)
435 .then_with(|| self.0.worktree_id.cmp(&other.0.worktree_id))
436 .then_with(|| {
437 other
438 .0
439 .distance_to_relative_ancestor
440 .cmp(&self.0.distance_to_relative_ancestor)
441 })
442 .then_with(|| self.0.path.cmp(&other.0.path).reverse())
443 }
444}
445
446impl PartialOrd for ProjectPanelOrdMatch {
447 fn partial_cmp(&self, other: &Self) -> Option<cmp::Ordering> {
448 Some(self.cmp(other))
449 }
450}
451
452#[derive(Debug, Default)]
453struct Matches {
454 separate_history: bool,
455 matches: Vec<Match>,
456}
457
458#[derive(Debug, Clone)]
459enum Match {
460 History {
461 path: FoundPath,
462 panel_match: Option<ProjectPanelOrdMatch>,
463 },
464 Search(ProjectPanelOrdMatch),
465 Channel {
466 channel_id: ChannelId,
467 channel_name: SharedString,
468 string_match: StringMatch,
469 },
470 CreateNew(ProjectPath),
471}
472
473impl Match {
474 fn relative_path(&self) -> Option<&Arc<RelPath>> {
475 match self {
476 Match::History { path, .. } => Some(&path.project.path),
477 Match::Search(panel_match) => Some(&panel_match.0.path),
478 Match::Channel { .. } | Match::CreateNew(_) => None,
479 }
480 }
481
482 fn abs_path(&self, project: &Entity<Project>, cx: &App) -> Option<PathBuf> {
483 match self {
484 Match::History { path, .. } => Some(path.absolute.clone()),
485 Match::Search(ProjectPanelOrdMatch(path_match)) => Some(
486 project
487 .read(cx)
488 .worktree_for_id(WorktreeId::from_usize(path_match.worktree_id), cx)?
489 .read(cx)
490 .absolutize(&path_match.path),
491 ),
492 Match::Channel { .. } | Match::CreateNew(_) => None,
493 }
494 }
495
496 fn panel_match(&self) -> Option<&ProjectPanelOrdMatch> {
497 match self {
498 Match::History { panel_match, .. } => panel_match.as_ref(),
499 Match::Search(panel_match) => Some(panel_match),
500 Match::Channel { .. } | Match::CreateNew(_) => None,
501 }
502 }
503}
504
505impl Matches {
506 fn len(&self) -> usize {
507 self.matches.len()
508 }
509
510 fn get(&self, index: usize) -> Option<&Match> {
511 self.matches.get(index)
512 }
513
514 fn position(
515 &self,
516 entry: &Match,
517 currently_opened: Option<&FoundPath>,
518 ) -> Result<usize, usize> {
519 if let Match::History {
520 path,
521 panel_match: None,
522 } = entry
523 {
524 // Slow case: linear search by path. Should not happen actually,
525 // since we call `position` only if matches set changed, but the query has not changed.
526 // And History entries do not have panel_match if query is empty, so there's no
527 // reason for the matches set to change.
528 self.matches
529 .iter()
530 .position(|m| match m.relative_path() {
531 Some(p) => path.project.path == *p,
532 None => false,
533 })
534 .ok_or(0)
535 } else {
536 self.matches.binary_search_by(|m| {
537 // `reverse()` since if cmp_matches(a, b) == Ordering::Greater, then a is better than b.
538 // And we want the better entries go first.
539 Self::cmp_matches(self.separate_history, currently_opened, m, entry).reverse()
540 })
541 }
542 }
543
544 fn push_new_matches<'a>(
545 &'a mut self,
546 worktree_store: Entity<WorktreeStore>,
547 cx: &'a App,
548 history_items: impl IntoIterator<Item = &'a FoundPath> + Clone,
549 currently_opened: Option<&'a FoundPath>,
550 query: Option<&FileSearchQuery>,
551 new_search_matches: impl Iterator<Item = ProjectPanelOrdMatch>,
552 extend_old_matches: bool,
553 path_style: PathStyle,
554 ) {
555 let Some(query) = query else {
556 // assuming that if there's no query, then there's no search matches.
557 self.matches.clear();
558 let path_to_entry = |found_path: &FoundPath| Match::History {
559 path: found_path.clone(),
560 panel_match: None,
561 };
562
563 self.matches
564 .extend(history_items.into_iter().map(path_to_entry));
565 return;
566 };
567
568 let worktree_name_by_id = if should_hide_root_in_entry_path(&worktree_store, cx) {
569 None
570 } else {
571 Some(
572 worktree_store
573 .read(cx)
574 .worktrees()
575 .map(|worktree| {
576 let snapshot = worktree.read(cx).snapshot();
577 (snapshot.id(), snapshot.root_name().into())
578 })
579 .collect(),
580 )
581 };
582 let new_history_matches = matching_history_items(
583 history_items,
584 currently_opened,
585 worktree_name_by_id,
586 query,
587 path_style,
588 );
589 let new_search_matches: Vec<Match> = new_search_matches
590 .filter(|path_match| {
591 !new_history_matches.contains_key(&ProjectPath {
592 path: path_match.0.path.clone(),
593 worktree_id: WorktreeId::from_usize(path_match.0.worktree_id),
594 })
595 })
596 .map(Match::Search)
597 .collect();
598
599 if extend_old_matches {
600 // since we take history matches instead of new search matches
601 // and history matches has not changed(since the query has not changed and we do not extend old matches otherwise),
602 // old matches can't contain paths present in history_matches as well.
603 self.matches.retain(|m| matches!(m, Match::Search(_)));
604 } else {
605 self.matches.clear();
606 }
607
608 // At this point we have an unsorted set of new history matches, an unsorted set of new search matches
609 // and a sorted set of old search matches.
610 // It is possible that the new search matches' paths contain some of the old search matches' paths.
611 // History matches' paths are unique, since store in a HashMap by path.
612 // We build a sorted Vec<Match>, eliminating duplicate search matches.
613 // Search matches with the same paths should have equal `ProjectPanelOrdMatch`, so we should
614 // not have any duplicates after building the final list.
615 for new_match in new_history_matches
616 .into_values()
617 .chain(new_search_matches.into_iter())
618 {
619 match self.position(&new_match, currently_opened) {
620 Ok(_duplicate) => continue,
621 Err(i) => {
622 self.matches.insert(i, new_match);
623 if self.matches.len() == 100 {
624 break;
625 }
626 }
627 }
628 }
629 }
630
631 /// If a < b, then a is a worse match, aligning with the `ProjectPanelOrdMatch` ordering.
632 fn cmp_matches(
633 separate_history: bool,
634 currently_opened: Option<&FoundPath>,
635 a: &Match,
636 b: &Match,
637 ) -> cmp::Ordering {
638 // Handle CreateNew variant - always put it at the end
639 match (a, b) {
640 (Match::CreateNew(_), _) => return cmp::Ordering::Less,
641 (_, Match::CreateNew(_)) => return cmp::Ordering::Greater,
642 _ => {}
643 }
644
645 match (&a, &b) {
646 // bubble currently opened files to the top
647 (Match::History { path, .. }, _) if Some(path) == currently_opened => {
648 return cmp::Ordering::Greater;
649 }
650 (_, Match::History { path, .. }) if Some(path) == currently_opened => {
651 return cmp::Ordering::Less;
652 }
653
654 _ => {}
655 }
656
657 if separate_history {
658 match (a, b) {
659 (Match::History { .. }, Match::Search(_)) => return cmp::Ordering::Greater,
660 (Match::Search(_), Match::History { .. }) => return cmp::Ordering::Less,
661
662 _ => {}
663 }
664 }
665
666 // For file-vs-file matches, use the existing detailed comparison.
667 if let (Some(a_panel), Some(b_panel)) = (a.panel_match(), b.panel_match()) {
668 return a_panel.cmp(b_panel);
669 }
670
671 let a_score = Self::match_score(a);
672 let b_score = Self::match_score(b);
673 // When at least one side is a channel, compare by raw score.
674 a_score
675 .partial_cmp(&b_score)
676 .unwrap_or(cmp::Ordering::Equal)
677 }
678
679 fn match_score(m: &Match) -> f64 {
680 match m {
681 Match::History { panel_match, .. } => panel_match.as_ref().map_or(0.0, |pm| pm.0.score),
682 Match::Search(pm) => pm.0.score,
683 Match::Channel { string_match, .. } => string_match.score,
684 Match::CreateNew(_) => 0.0,
685 }
686 }
687}
688
689fn matching_history_items<'a>(
690 history_items: impl IntoIterator<Item = &'a FoundPath>,
691 currently_opened: Option<&'a FoundPath>,
692 worktree_name_by_id: Option<HashMap<WorktreeId, Arc<RelPath>>>,
693 query: &FileSearchQuery,
694 path_style: PathStyle,
695) -> HashMap<ProjectPath, Match> {
696 let mut candidates_paths = HashMap::default();
697
698 let history_items_by_worktrees = history_items
699 .into_iter()
700 .chain(currently_opened)
701 .map(|found_path| {
702 // Only match history items names, otherwise their paths may match too many queries,
703 // producing false positives. E.g. `foo` would match both `something/foo/bar.rs` and
704 // `something/foo/foo.rs` and if the former is a history item, it would be shown first
705 // always, despite the latter being a better match.
706 let candidate = PathMatchCandidate::new(
707 &found_path.project.path,
708 false,
709 worktree_name_by_id
710 .as_ref()
711 .and_then(|m| m.get(&found_path.project.worktree_id))
712 .map(|prefix| prefix.as_ref()),
713 );
714 candidates_paths.insert(&found_path.project, found_path);
715 (found_path.project.worktree_id, candidate)
716 })
717 .fold(
718 HashMap::default(),
719 |mut candidates, (worktree_id, new_candidate)| {
720 candidates
721 .entry(worktree_id)
722 .or_insert_with(Vec::new)
723 .push(new_candidate);
724 candidates
725 },
726 );
727 let mut matching_history_paths = HashMap::default();
728 for (worktree, candidates) in history_items_by_worktrees {
729 let max_results = candidates.len() + 1;
730 let worktree_root_name = worktree_name_by_id
731 .as_ref()
732 .and_then(|w| w.get(&worktree).cloned());
733
734 matching_history_paths.extend(
735 fuzzy_nucleo::match_fixed_path_set(
736 candidates,
737 worktree.to_usize(),
738 worktree_root_name,
739 query.path_query(),
740 fuzzy_nucleo::Case::Ignore,
741 max_results,
742 path_style,
743 )
744 .into_iter()
745 // filter matches where at least one matched position is in filename portion, to prevent directory matches, nucleo scores them higher as history items are matched against their full path
746 .filter(|path_match| {
747 if let Some(filename) = path_match.path.file_name() {
748 let filename_start = path_match.path.as_unix_str().len() - filename.len();
749 path_match
750 .positions
751 .iter()
752 .any(|&pos| pos >= filename_start)
753 } else {
754 true
755 }
756 })
757 .filter_map(|path_match| {
758 candidates_paths
759 .remove_entry(&ProjectPath {
760 worktree_id: WorktreeId::from_usize(path_match.worktree_id),
761 path: Arc::clone(&path_match.path),
762 })
763 .map(|(project_path, found_path)| {
764 (
765 project_path.clone(),
766 Match::History {
767 path: found_path.clone(),
768 panel_match: Some(ProjectPanelOrdMatch(path_match)),
769 },
770 )
771 })
772 }),
773 );
774 }
775 matching_history_paths
776}
777
778fn should_hide_root_in_entry_path(worktree_store: &Entity<WorktreeStore>, cx: &App) -> bool {
779 let multiple_worktrees = worktree_store
780 .read(cx)
781 .visible_worktrees(cx)
782 .filter(|worktree| !worktree.read(cx).is_single_file())
783 .nth(1)
784 .is_some();
785 ProjectPanelSettings::get_global(cx).hide_root && !multiple_worktrees
786}
787
788#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
789struct FoundPath {
790 project: ProjectPath,
791 absolute: PathBuf,
792}
793
794impl FoundPath {
795 fn new(project: ProjectPath, absolute: PathBuf) -> Self {
796 Self { project, absolute }
797 }
798}
799
800const MAX_RECENT_SELECTIONS: usize = 20;
801
802pub enum Event {
803 Selected(ProjectPath),
804 Dismissed,
805}
806
807#[derive(Debug, Clone)]
808struct FileSearchQuery {
809 raw_query: String,
810 file_query_end: Option<usize>,
811 path_position: PathWithPosition,
812}
813
814impl FileSearchQuery {
815 fn path_query(&self) -> &str {
816 match self.file_query_end {
817 Some(file_path_end) => &self.raw_query[..file_path_end],
818 None => &self.raw_query,
819 }
820 }
821}
822
823impl FileFinderDelegate {
824 fn new(
825 file_finder: WeakEntity<FileFinder>,
826 workspace: WeakEntity<Workspace>,
827 project: Entity<Project>,
828 currently_opened_path: Option<FoundPath>,
829 history_items: Vec<FoundPath>,
830 separate_history: bool,
831 window: &mut Window,
832 cx: &mut Context<FileFinder>,
833 ) -> Self {
834 Self::subscribe_to_updates(&project, window, cx);
835 let channel_store = if FileFinderSettings::get_global(cx).include_channels {
836 ChannelStore::try_global(cx)
837 } else {
838 None
839 };
840 Self {
841 file_finder,
842 workspace,
843 project,
844 channel_store,
845 search_count: 0,
846 latest_search_id: 0,
847 latest_search_did_cancel: false,
848 latest_search_query: None,
849 currently_opened_path,
850 matches: Matches::default(),
851 has_changed_selected_index: false,
852 selected_index: 0,
853 cancel_flag: Arc::new(AtomicBool::new(false)),
854 history_items,
855 separate_history,
856 first_update: true,
857 filter_popover_menu_handle: PopoverMenuHandle::default(),
858 split_popover_menu_handle: PopoverMenuHandle::default(),
859 focus_handle: cx.focus_handle(),
860 include_ignored: FileFinderSettings::get_global(cx).include_ignored,
861 include_ignored_refresh: Task::ready(()),
862 }
863 }
864
865 fn subscribe_to_updates(
866 project: &Entity<Project>,
867 window: &mut Window,
868 cx: &mut Context<FileFinder>,
869 ) {
870 cx.subscribe_in(project, window, |file_finder, _, event, window, cx| {
871 match event {
872 project::Event::WorktreeUpdatedEntries(_, _)
873 | project::Event::WorktreeAdded(_)
874 | project::Event::WorktreeRemoved(_) => file_finder
875 .picker
876 .update(cx, |picker, cx| picker.refresh(window, cx)),
877 _ => {}
878 };
879 })
880 .detach();
881 }
882
883 fn spawn_search(
884 &mut self,
885 query: FileSearchQuery,
886 window: &mut Window,
887 cx: &mut Context<Picker<Self>>,
888 ) -> Task<()> {
889 let relative_to = self
890 .currently_opened_path
891 .as_ref()
892 .map(|found_path| Arc::clone(&found_path.project.path));
893 let worktree_store = self.project.read(cx).worktree_store();
894 let worktrees = worktree_store
895 .read(cx)
896 .visible_worktrees_and_single_files(cx)
897 .collect::<Vec<_>>();
898 let include_root_name = !should_hide_root_in_entry_path(&worktree_store, cx);
899 let candidate_sets = worktrees
900 .into_iter()
901 .map(|worktree| {
902 let worktree = worktree.read(cx);
903 PathMatchCandidateSet {
904 snapshot: worktree.snapshot(),
905 include_ignored: self.include_ignored.unwrap_or_else(|| {
906 worktree.root_entry().is_some_and(|entry| entry.is_ignored)
907 }),
908 include_root_name,
909 candidates: project::Candidates::Files,
910 }
911 })
912 .collect::<Vec<_>>();
913
914 let search_id = util::post_inc(&mut self.search_count);
915 self.cancel_flag.store(true, atomic::Ordering::Release);
916 self.cancel_flag = Arc::new(AtomicBool::new(false));
917 let cancel_flag = self.cancel_flag.clone();
918 cx.spawn_in(window, async move |picker, cx| {
919 let matches = fuzzy_nucleo::match_path_sets(
920 candidate_sets.as_slice(),
921 query.path_query(),
922 &relative_to,
923 fuzzy_nucleo::Case::Ignore,
924 100,
925 &cancel_flag,
926 cx.background_executor().clone(),
927 )
928 .await
929 .into_iter()
930 .map(ProjectPanelOrdMatch);
931 let did_cancel = cancel_flag.load(atomic::Ordering::Acquire);
932 picker
933 .update(cx, |picker, cx| {
934 picker
935 .delegate
936 .set_search_matches(search_id, did_cancel, query, matches, cx)
937 })
938 .log_err();
939 })
940 }
941
942 fn set_search_matches(
943 &mut self,
944 search_id: usize,
945 did_cancel: bool,
946 query: FileSearchQuery,
947 matches: impl IntoIterator<Item = ProjectPanelOrdMatch>,
948 cx: &mut Context<Picker<Self>>,
949 ) {
950 if search_id >= self.latest_search_id {
951 self.latest_search_id = search_id;
952 let query_changed = Some(query.path_query())
953 != self
954 .latest_search_query
955 .as_ref()
956 .map(|query| query.path_query());
957 let extend_old_matches = self.latest_search_did_cancel && !query_changed;
958
959 let selected_match = if query_changed {
960 None
961 } else {
962 self.matches.get(self.selected_index).cloned()
963 };
964
965 let path_style = self.project.read(cx).path_style(cx);
966 self.matches.push_new_matches(
967 self.project.read(cx).worktree_store(),
968 cx,
969 &self.history_items,
970 self.currently_opened_path.as_ref(),
971 Some(&query),
972 matches.into_iter(),
973 extend_old_matches,
974 path_style,
975 );
976
977 // Add channel matches
978 if let Some(channel_store) = &self.channel_store {
979 let channel_store = channel_store.read(cx);
980 let channels: Vec<_> = channel_store.channels().cloned().collect();
981 if !channels.is_empty() {
982 let candidates = channels
983 .iter()
984 .enumerate()
985 .map(|(id, channel)| StringMatchCandidate::new(id, &channel.name));
986 let channel_query = query.path_query();
987 let query_lower = channel_query.to_lowercase();
988 let mut channel_matches = Vec::new();
989 for candidate in candidates {
990 let channel_name = candidate.string;
991 let name_lower = channel_name.to_lowercase();
992
993 let mut positions = Vec::new();
994 let mut query_idx = 0;
995 for (name_idx, name_char) in name_lower.char_indices() {
996 if query_idx < query_lower.len() {
997 let query_char =
998 query_lower[query_idx..].chars().next().unwrap_or_default();
999 if name_char == query_char {
1000 positions.push(name_idx);
1001 query_idx += query_char.len_utf8();
1002 }
1003 }
1004 }
1005
1006 if query_idx == query_lower.len() {
1007 let channel = &channels[candidate.id];
1008 let score = if name_lower == query_lower {
1009 1.0
1010 } else if name_lower.starts_with(&query_lower) {
1011 0.8
1012 } else {
1013 0.5 * (query_lower.len() as f64 / name_lower.len() as f64)
1014 };
1015 channel_matches.push(Match::Channel {
1016 channel_id: channel.id,
1017 channel_name: channel.name.clone(),
1018 string_match: StringMatch {
1019 candidate_id: candidate.id,
1020 score,
1021 positions,
1022 string: channel_name,
1023 },
1024 });
1025 }
1026 }
1027 for channel_match in channel_matches {
1028 match self
1029 .matches
1030 .position(&channel_match, self.currently_opened_path.as_ref())
1031 {
1032 Ok(_duplicate) => {}
1033 Err(ix) => self.matches.matches.insert(ix, channel_match),
1034 }
1035 }
1036 }
1037 }
1038
1039 let query_path = query.raw_query.as_str();
1040 if let Ok(mut query_path) = RelPath::new(Path::new(query_path), path_style) {
1041 let available_worktree = self
1042 .project
1043 .read(cx)
1044 .visible_worktrees(cx)
1045 .filter(|worktree| !worktree.read(cx).is_single_file())
1046 .collect::<Vec<_>>();
1047 let worktree_count = available_worktree.len();
1048 let mut expect_worktree = available_worktree.first().cloned();
1049 for worktree in &available_worktree {
1050 let worktree_root = worktree.read(cx).root_name();
1051 if worktree_count > 1 {
1052 if let Ok(suffix) = query_path.strip_prefix(worktree_root) {
1053 query_path = Cow::Owned(suffix.to_owned());
1054 expect_worktree = Some(worktree.clone());
1055 break;
1056 }
1057 }
1058 }
1059
1060 if let Some(FoundPath { ref project, .. }) = self.currently_opened_path {
1061 let worktree_id = project.worktree_id;
1062 let focused_file_in_available_worktree = available_worktree
1063 .iter()
1064 .any(|wt| wt.read(cx).id() == worktree_id);
1065
1066 if focused_file_in_available_worktree {
1067 expect_worktree = self.project.read(cx).worktree_for_id(worktree_id, cx);
1068 }
1069 }
1070
1071 if let Some(worktree) = expect_worktree {
1072 let worktree = worktree.read(cx);
1073 if worktree.entry_for_path(&query_path).is_none()
1074 && !query.raw_query.ends_with("/")
1075 && !(path_style.is_windows() && query.raw_query.ends_with("\\"))
1076 {
1077 self.matches.matches.push(Match::CreateNew(ProjectPath {
1078 worktree_id: worktree.id(),
1079 path: query_path.into_arc(),
1080 }));
1081 }
1082 }
1083 }
1084
1085 self.selected_index = selected_match.map_or_else(
1086 || self.calculate_selected_index(cx),
1087 |m| {
1088 self.matches
1089 .position(&m, self.currently_opened_path.as_ref())
1090 .unwrap_or(0)
1091 },
1092 );
1093
1094 self.latest_search_query = Some(query);
1095 self.latest_search_did_cancel = did_cancel;
1096
1097 cx.notify();
1098 }
1099 }
1100
1101 fn labels_for_match(
1102 &self,
1103 path_match: &Match,
1104 window: &mut Window,
1105 cx: &App,
1106 ) -> (HighlightedLabel, HighlightedLabel) {
1107 let path_style = self.project.read(cx).path_style(cx);
1108 let (file_name, file_name_positions, mut full_path, mut full_path_positions) =
1109 match &path_match {
1110 Match::History {
1111 path: entry_path,
1112 panel_match,
1113 } => {
1114 let worktree_id = entry_path.project.worktree_id;
1115 let worktree = self
1116 .project
1117 .read(cx)
1118 .worktree_for_id(worktree_id, cx)
1119 .filter(|worktree| worktree.read(cx).is_visible());
1120
1121 if let Some(panel_match) = panel_match {
1122 self.labels_for_path_match(&panel_match.0, path_style)
1123 } else if let Some(worktree) = worktree {
1124 let worktree_store = self.project.read(cx).worktree_store();
1125 let full_path = if should_hide_root_in_entry_path(&worktree_store, cx) {
1126 entry_path.project.path.clone()
1127 } else {
1128 worktree.read(cx).root_name().join(&entry_path.project.path)
1129 };
1130 let mut components = full_path.components();
1131 let filename = components.next_back().unwrap_or("");
1132 let prefix = components.rest();
1133 (
1134 filename.to_string(),
1135 Vec::new(),
1136 prefix.display(path_style).to_string() + path_style.primary_separator(),
1137 Vec::new(),
1138 )
1139 } else {
1140 (
1141 entry_path
1142 .absolute
1143 .file_name()
1144 .map_or(String::new(), |f| f.to_string_lossy().into_owned()),
1145 Vec::new(),
1146 entry_path.absolute.parent().map_or(String::new(), |path| {
1147 path.to_string_lossy().into_owned() + path_style.primary_separator()
1148 }),
1149 Vec::new(),
1150 )
1151 }
1152 }
1153 Match::Search(path_match) => self.labels_for_path_match(&path_match.0, path_style),
1154 Match::Channel {
1155 channel_name,
1156 string_match,
1157 ..
1158 } => (
1159 channel_name.to_string(),
1160 string_match.positions.clone(),
1161 "Channel Notes".to_string(),
1162 vec![],
1163 ),
1164 Match::CreateNew(project_path) => (
1165 format!("Create file: {}", project_path.path.display(path_style)),
1166 vec![],
1167 String::from(""),
1168 vec![],
1169 ),
1170 };
1171
1172 if file_name_positions.is_empty() {
1173 let user_home_path = util::paths::home_dir().to_string_lossy();
1174 if !user_home_path.is_empty() && full_path.starts_with(&*user_home_path) {
1175 full_path.replace_range(0..user_home_path.len(), "~");
1176 full_path_positions.retain_mut(|pos| {
1177 if *pos >= user_home_path.len() {
1178 *pos -= user_home_path.len();
1179 *pos += 1;
1180 true
1181 } else {
1182 false
1183 }
1184 })
1185 }
1186 }
1187
1188 if full_path.is_ascii() {
1189 let file_finder_settings = FileFinderSettings::get_global(cx);
1190 let max_width =
1191 FileFinder::modal_max_width(file_finder_settings.modal_max_width, window);
1192 let (normal_em, small_em) = {
1193 let style = window.text_style();
1194 let font_id = window.text_system().resolve_font(&style.font());
1195 let font_size = TextSize::Default.rems(cx).to_pixels(window.rem_size());
1196 let normal = cx
1197 .text_system()
1198 .em_width(font_id, font_size)
1199 .unwrap_or(px(16.));
1200 let font_size = TextSize::Small.rems(cx).to_pixels(window.rem_size());
1201 let small = cx
1202 .text_system()
1203 .em_width(font_id, font_size)
1204 .unwrap_or(px(10.));
1205 (normal, small)
1206 };
1207 let budget = full_path_budget(&file_name, normal_em, small_em, max_width);
1208 // If the computed budget is zero, we certainly won't be able to achieve it,
1209 // so no point trying to elide the path.
1210 if budget > 0 && full_path.len() > budget {
1211 let components = PathComponentSlice::new(&full_path);
1212 if let Some(elided_range) =
1213 components.elision_range(budget - 1, &full_path_positions)
1214 {
1215 let elided_len = elided_range.end - elided_range.start;
1216 let placeholder = "…";
1217 full_path_positions.retain_mut(|mat| {
1218 if *mat >= elided_range.end {
1219 *mat -= elided_len;
1220 *mat += placeholder.len();
1221 } else if *mat >= elided_range.start {
1222 return false;
1223 }
1224 true
1225 });
1226 full_path.replace_range(elided_range, placeholder);
1227 }
1228 }
1229 }
1230
1231 (
1232 HighlightedLabel::new(file_name, file_name_positions),
1233 HighlightedLabel::new(full_path, full_path_positions)
1234 .size(LabelSize::Small)
1235 .color(Color::Muted),
1236 )
1237 }
1238
1239 fn labels_for_path_match(
1240 &self,
1241 path_match: &PathMatch,
1242 path_style: PathStyle,
1243 ) -> (String, Vec<usize>, String, Vec<usize>) {
1244 let full_path = path_match.path_prefix.join(&path_match.path);
1245 let mut path_positions = path_match.positions.clone();
1246
1247 let file_name = full_path.file_name().unwrap_or("");
1248 let file_name_start = full_path.as_unix_str().len() - file_name.len();
1249 let file_name_positions = path_positions
1250 .iter()
1251 .filter_map(|pos| {
1252 if pos >= &file_name_start {
1253 Some(pos - file_name_start)
1254 } else {
1255 None
1256 }
1257 })
1258 .collect::<Vec<_>>();
1259
1260 let full_path = full_path
1261 .display(path_style)
1262 .trim_end_matches(&file_name)
1263 .to_string();
1264 path_positions.retain(|idx| *idx < full_path.len());
1265
1266 debug_assert!(
1267 file_name_positions
1268 .iter()
1269 .all(|ix| file_name[*ix..].chars().next().is_some()),
1270 "invalid file name positions {file_name:?} {file_name_positions:?}"
1271 );
1272 debug_assert!(
1273 path_positions
1274 .iter()
1275 .all(|ix| full_path[*ix..].chars().next().is_some()),
1276 "invalid path positions {full_path:?} {path_positions:?}"
1277 );
1278
1279 (
1280 file_name.to_string(),
1281 file_name_positions,
1282 full_path,
1283 path_positions,
1284 )
1285 }
1286
1287 /// Attempts to resolve an absolute file path and update the search matches if found.
1288 ///
1289 /// If the query path resolves to an absolute file that exists in the project,
1290 /// this method will find the corresponding worktree and relative path, create a
1291 /// match for it, and update the picker's search results.
1292 ///
1293 /// Returns `true` if the absolute path exists, otherwise returns `false`.
1294 fn lookup_absolute_path(
1295 &self,
1296 query: FileSearchQuery,
1297 window: &mut Window,
1298 cx: &mut Context<Picker<Self>>,
1299 ) -> Task<bool> {
1300 cx.spawn_in(window, async move |picker, cx| {
1301 let Some(project) = picker
1302 .read_with(cx, |picker, _| picker.delegate.project.clone())
1303 .log_err()
1304 else {
1305 return false;
1306 };
1307
1308 let query_path = Path::new(query.path_query());
1309 let mut path_matches = Vec::new();
1310
1311 let abs_file_exists = project
1312 .update(cx, |this, cx| {
1313 this.resolve_abs_file_path(query.path_query(), cx)
1314 })
1315 .await
1316 .is_some();
1317
1318 if abs_file_exists {
1319 project.update(cx, |project, cx| {
1320 if let Some((worktree, relative_path)) = project.find_worktree(query_path, cx) {
1321 path_matches.push(ProjectPanelOrdMatch(PathMatch {
1322 score: 1.0,
1323 positions: Vec::new(),
1324 worktree_id: worktree.read(cx).id().to_usize(),
1325 path: relative_path,
1326 path_prefix: RelPath::empty().into(),
1327 is_dir: false, // File finder doesn't support directories
1328 distance_to_relative_ancestor: usize::MAX,
1329 }));
1330 }
1331 });
1332 }
1333
1334 picker
1335 .update_in(cx, |picker, _, cx| {
1336 let picker_delegate = &mut picker.delegate;
1337 let search_id = util::post_inc(&mut picker_delegate.search_count);
1338 picker_delegate.set_search_matches(search_id, false, query, path_matches, cx);
1339
1340 anyhow::Ok(())
1341 })
1342 .log_err();
1343 abs_file_exists
1344 })
1345 }
1346
1347 /// Skips first history match (that is displayed topmost) if it's currently opened.
1348 fn calculate_selected_index(&self, cx: &mut Context<Picker<Self>>) -> usize {
1349 if FileFinderSettings::get_global(cx).skip_focus_for_active_in_search
1350 && let Some(Match::History { path, .. }) = self.matches.get(0)
1351 && Some(path) == self.currently_opened_path.as_ref()
1352 {
1353 let elements_after_first = self.matches.len() - 1;
1354 if elements_after_first > 0 {
1355 return 1;
1356 }
1357 }
1358
1359 0
1360 }
1361
1362 fn key_context(&self, window: &Window, cx: &App) -> KeyContext {
1363 let mut key_context = KeyContext::new_with_defaults();
1364 key_context.add("FileFinder");
1365
1366 if self.filter_popover_menu_handle.is_focused(window, cx) {
1367 key_context.add("filter_menu_open");
1368 }
1369
1370 if self.split_popover_menu_handle.is_focused(window, cx) {
1371 key_context.add("split_menu_open");
1372 }
1373 key_context
1374 }
1375}
1376
1377fn full_path_budget(
1378 file_name: &str,
1379 normal_em: Pixels,
1380 small_em: Pixels,
1381 max_width: Pixels,
1382) -> usize {
1383 (((max_width / 0.8) - file_name.len() * normal_em) / small_em) as usize
1384}
1385
1386impl PickerDelegate for FileFinderDelegate {
1387 type ListItem = ListItem;
1388
1389 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
1390 "Search project files...".into()
1391 }
1392
1393 fn match_count(&self) -> usize {
1394 self.matches.len()
1395 }
1396
1397 fn selected_index(&self) -> usize {
1398 self.selected_index
1399 }
1400
1401 fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
1402 self.has_changed_selected_index = true;
1403 self.selected_index = ix;
1404 cx.notify();
1405 }
1406
1407 fn separators_after_indices(&self) -> Vec<usize> {
1408 if self.separate_history {
1409 let first_non_history_index = self
1410 .matches
1411 .matches
1412 .iter()
1413 .enumerate()
1414 .find(|(_, m)| !matches!(m, Match::History { .. }))
1415 .map(|(i, _)| i);
1416 if let Some(first_non_history_index) = first_non_history_index
1417 && first_non_history_index > 0
1418 {
1419 return vec![first_non_history_index - 1];
1420 }
1421 }
1422 Vec::new()
1423 }
1424
1425 fn update_matches(
1426 &mut self,
1427 raw_query: String,
1428 window: &mut Window,
1429 cx: &mut Context<Picker<Self>>,
1430 ) -> Task<()> {
1431 let raw_query = raw_query.trim();
1432
1433 let raw_query = match &raw_query.get(0..2) {
1434 Some(".\\" | "./") => &raw_query[2..],
1435 Some(prefix @ ("a\\" | "a/" | "b\\" | "b/")) => {
1436 if self
1437 .workspace
1438 .upgrade()
1439 .into_iter()
1440 .flat_map(|workspace| workspace.read(cx).worktrees(cx))
1441 .all(|worktree| {
1442 worktree
1443 .read(cx)
1444 .entry_for_path(RelPath::unix(prefix.split_at(1).0).unwrap())
1445 .is_none_or(|entry| !entry.is_dir())
1446 })
1447 {
1448 &raw_query[2..]
1449 } else {
1450 raw_query
1451 }
1452 }
1453 _ => raw_query,
1454 };
1455
1456 if raw_query.is_empty() {
1457 // if there was no query before, and we already have some (history) matches
1458 // there's no need to update anything, since nothing has changed.
1459 // We also want to populate matches set from history entries on the first update.
1460 if self.latest_search_query.is_some() || self.first_update {
1461 let project = self.project.read(cx);
1462
1463 self.latest_search_id = post_inc(&mut self.search_count);
1464 self.latest_search_query = None;
1465 self.matches = Matches {
1466 separate_history: self.separate_history,
1467 ..Matches::default()
1468 };
1469 let path_style = self.project.read(cx).path_style(cx);
1470
1471 self.matches.push_new_matches(
1472 project.worktree_store(),
1473 cx,
1474 self.history_items.iter().filter(|history_item| {
1475 project
1476 .worktree_for_id(history_item.project.worktree_id, cx)
1477 .is_some()
1478 || project.is_local()
1479 || project.is_via_remote_server()
1480 }),
1481 self.currently_opened_path.as_ref(),
1482 None,
1483 None.into_iter(),
1484 false,
1485 path_style,
1486 );
1487
1488 self.first_update = false;
1489 self.selected_index = 0;
1490 }
1491 cx.notify();
1492 Task::ready(())
1493 } else {
1494 let path_position = PathWithPosition::parse_str(raw_query);
1495 let raw_query = raw_query.trim().trim_end_matches(':').to_owned();
1496 let path = path_position.path.clone();
1497 let path_str = path_position.path.to_str();
1498 let path_trimmed = path_str.unwrap_or(&raw_query).trim_end_matches(':');
1499 let file_query_end = if path_trimmed == raw_query {
1500 None
1501 } else {
1502 // Safe to unwrap as we won't get here when the unwrap in if fails
1503 Some(path_str.unwrap().len())
1504 };
1505
1506 let query = FileSearchQuery {
1507 raw_query,
1508 file_query_end,
1509 path_position,
1510 };
1511
1512 cx.spawn_in(window, async move |this, cx| {
1513 let _ = maybe!(async move {
1514 let is_absolute_path = path.is_absolute();
1515 let did_resolve_abs_path = is_absolute_path
1516 && this
1517 .update_in(cx, |this, window, cx| {
1518 this.delegate
1519 .lookup_absolute_path(query.clone(), window, cx)
1520 })?
1521 .await;
1522
1523 // Only check for relative paths if no absolute paths were
1524 // found.
1525 if !did_resolve_abs_path {
1526 this.update_in(cx, |this, window, cx| {
1527 this.delegate.spawn_search(query, window, cx)
1528 })?
1529 .await;
1530 }
1531 anyhow::Ok(())
1532 })
1533 .await;
1534 })
1535 }
1536 }
1537
1538 fn confirm(
1539 &mut self,
1540 secondary: bool,
1541 window: &mut Window,
1542 cx: &mut Context<Picker<FileFinderDelegate>>,
1543 ) {
1544 if let Some(m) = self.matches.get(self.selected_index())
1545 && let Some(workspace) = self.workspace.upgrade()
1546 {
1547 // Channel matches are handled separately since they dispatch an action
1548 // rather than directly opening a file path.
1549 if let Match::Channel { channel_id, .. } = m {
1550 let channel_id = channel_id.0;
1551 let finder = self.file_finder.clone();
1552 window.dispatch_action(OpenChannelNotesById { channel_id }.boxed_clone(), cx);
1553 finder.update(cx, |_, cx| cx.emit(DismissEvent)).log_err();
1554 return;
1555 }
1556
1557 let open_task = workspace.update(cx, |workspace, cx| {
1558 let split_or_open =
1559 |workspace: &mut Workspace,
1560 project_path,
1561 window: &mut Window,
1562 cx: &mut Context<Workspace>| {
1563 let allow_preview =
1564 PreviewTabsSettings::get_global(cx).enable_preview_from_file_finder;
1565 if secondary {
1566 workspace.split_path_preview(
1567 project_path,
1568 allow_preview,
1569 None,
1570 window,
1571 cx,
1572 )
1573 } else {
1574 workspace.open_path_preview(
1575 project_path,
1576 None,
1577 true,
1578 allow_preview,
1579 true,
1580 window,
1581 cx,
1582 )
1583 }
1584 };
1585 match &m {
1586 Match::CreateNew(project_path) => {
1587 // Create a new file with the given filename
1588 if secondary {
1589 workspace.split_path_preview(
1590 project_path.clone(),
1591 false,
1592 None,
1593 window,
1594 cx,
1595 )
1596 } else {
1597 workspace.open_path_preview(
1598 project_path.clone(),
1599 None,
1600 true,
1601 false,
1602 true,
1603 window,
1604 cx,
1605 )
1606 }
1607 }
1608
1609 Match::History { path, .. } => {
1610 let worktree_id = path.project.worktree_id;
1611 if workspace
1612 .project()
1613 .read(cx)
1614 .worktree_for_id(worktree_id, cx)
1615 .is_some()
1616 {
1617 split_or_open(
1618 workspace,
1619 ProjectPath {
1620 worktree_id,
1621 path: Arc::clone(&path.project.path),
1622 },
1623 window,
1624 cx,
1625 )
1626 } else if secondary {
1627 workspace.split_abs_path(path.absolute.clone(), false, window, cx)
1628 } else {
1629 workspace.open_abs_path(
1630 path.absolute.clone(),
1631 OpenOptions {
1632 visible: Some(OpenVisible::None),
1633 ..Default::default()
1634 },
1635 window,
1636 cx,
1637 )
1638 }
1639 }
1640 Match::Search(m) => split_or_open(
1641 workspace,
1642 ProjectPath {
1643 worktree_id: WorktreeId::from_usize(m.0.worktree_id),
1644 path: m.0.path.clone(),
1645 },
1646 window,
1647 cx,
1648 ),
1649 Match::Channel { .. } => unreachable!("handled above"),
1650 }
1651 });
1652
1653 let row = self
1654 .latest_search_query
1655 .as_ref()
1656 .and_then(|query| query.path_position.row)
1657 .map(|row| row.saturating_sub(1));
1658 let col = self
1659 .latest_search_query
1660 .as_ref()
1661 .and_then(|query| query.path_position.column)
1662 .unwrap_or(0)
1663 .saturating_sub(1);
1664 let finder = self.file_finder.clone();
1665 let workspace = self.workspace.clone();
1666
1667 cx.spawn_in(window, async move |_, mut cx| {
1668 let item = open_task
1669 .await
1670 .notify_workspace_async_err(workspace, &mut cx)?;
1671 if let Some(row) = row
1672 && let Some(active_editor) = item.downcast::<Editor>()
1673 {
1674 active_editor
1675 .downgrade()
1676 .update_in(cx, |editor, window, cx| {
1677 let Some(buffer) = editor.buffer().read(cx).as_singleton() else {
1678 return;
1679 };
1680 let buffer_snapshot = buffer.read(cx).snapshot();
1681 let point = buffer_snapshot.point_from_external_input(row, col);
1682 editor.go_to_singleton_buffer_point(point, window, cx);
1683 })
1684 .log_err();
1685 }
1686 finder.update(cx, |_, cx| cx.emit(DismissEvent)).ok()?;
1687
1688 Some(())
1689 })
1690 .detach();
1691 }
1692 }
1693
1694 fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<FileFinderDelegate>>) {
1695 self.file_finder
1696 .update(cx, |_, cx| cx.emit(DismissEvent))
1697 .log_err();
1698 }
1699
1700 fn render_match(
1701 &self,
1702 ix: usize,
1703 selected: bool,
1704 window: &mut Window,
1705 cx: &mut Context<Picker<Self>>,
1706 ) -> Option<Self::ListItem> {
1707 let settings = FileFinderSettings::get_global(cx);
1708
1709 let path_match = self.matches.get(ix)?;
1710
1711 let end_icon = match path_match {
1712 Match::History { .. } => Icon::new(IconName::HistoryRerun)
1713 .color(Color::Muted)
1714 .size(IconSize::Small)
1715 .into_any_element(),
1716 Match::Search(_) => v_flex()
1717 .flex_none()
1718 .size(IconSize::Small.rems())
1719 .into_any_element(),
1720 Match::Channel { .. } => v_flex()
1721 .flex_none()
1722 .size(IconSize::Small.rems())
1723 .into_any_element(),
1724 Match::CreateNew(_) => Icon::new(IconName::Plus)
1725 .color(Color::Muted)
1726 .size(IconSize::Small)
1727 .into_any_element(),
1728 };
1729 let (file_name_label, full_path_label) = self.labels_for_match(path_match, window, cx);
1730
1731 let file_icon = match path_match {
1732 Match::Channel { .. } => Some(Icon::new(IconName::Hash).color(Color::Muted)),
1733 _ => maybe!({
1734 if !settings.file_icons {
1735 return None;
1736 }
1737 let abs_path = path_match.abs_path(&self.project, cx)?;
1738 let file_name = abs_path.file_name()?;
1739 let icon = FileIcons::get_icon(file_name.as_ref(), cx)?;
1740 Some(Icon::from_path(icon).color(Color::Muted))
1741 }),
1742 };
1743
1744 Some(
1745 ListItem::new(ix)
1746 .spacing(ListItemSpacing::Sparse)
1747 .start_slot::<Icon>(file_icon)
1748 .end_slot::<AnyElement>(end_icon)
1749 .inset(true)
1750 .toggle_state(selected)
1751 .child(
1752 h_flex()
1753 .gap_2()
1754 .py_px()
1755 .child(file_name_label)
1756 .child(full_path_label),
1757 ),
1758 )
1759 }
1760
1761 fn render_editor(
1762 &self,
1763 editor: &Arc<dyn ErasedEditor>,
1764 window: &mut Window,
1765 cx: &mut Context<Picker<Self>>,
1766 ) -> Div {
1767 let has_search_query = self.latest_search_query.is_some();
1768 let is_project_scan_running = {
1769 let worktree_store = self.project.read(cx).worktree_store();
1770 !worktree_store.read(cx).initial_scan_completed()
1771 };
1772
1773 h_flex()
1774 .flex_none()
1775 .h_9()
1776 .px_2p5()
1777 .justify_between()
1778 .border_b_1()
1779 .border_color(cx.theme().colors().border_variant)
1780 .child(editor.render(window, cx))
1781 .when(is_project_scan_running && has_search_query, |this| {
1782 this.child(
1783 h_flex()
1784 .id("project-scan-indicator")
1785 .tooltip(Tooltip::text("Project Scan in Progress…"))
1786 .child(
1787 Icon::new(IconName::LoadCircle)
1788 .color(Color::Accent)
1789 .size(IconSize::Small)
1790 .with_rotate_animation(2),
1791 ),
1792 )
1793 })
1794 }
1795
1796 fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
1797 let focus_handle = self.focus_handle.clone();
1798
1799 Some(
1800 h_flex()
1801 .w_full()
1802 .p_1p5()
1803 .justify_between()
1804 .border_t_1()
1805 .border_color(cx.theme().colors().border_variant)
1806 .child(
1807 PopoverMenu::new("filter-menu-popover")
1808 .with_handle(self.filter_popover_menu_handle.clone())
1809 .attach(gpui::Anchor::BottomRight)
1810 .anchor(gpui::Anchor::BottomLeft)
1811 .offset(gpui::Point {
1812 x: px(1.0),
1813 y: px(1.0),
1814 })
1815 .trigger_with_tooltip(
1816 IconButton::new("filter-trigger", IconName::Sliders)
1817 .icon_size(IconSize::Small)
1818 .icon_size(IconSize::Small)
1819 .toggle_state(self.include_ignored.unwrap_or(false))
1820 .when(self.include_ignored.is_some(), |this| {
1821 this.indicator(Indicator::dot().color(Color::Info))
1822 }),
1823 {
1824 let focus_handle = focus_handle.clone();
1825 move |_window, cx| {
1826 Tooltip::for_action_in(
1827 "Filter Options",
1828 &ToggleFilterMenu,
1829 &focus_handle,
1830 cx,
1831 )
1832 }
1833 },
1834 )
1835 .menu({
1836 let focus_handle = focus_handle.clone();
1837 let include_ignored = self.include_ignored;
1838
1839 move |window, cx| {
1840 Some(ContextMenu::build(window, cx, {
1841 let focus_handle = focus_handle.clone();
1842 move |menu, _, _| {
1843 menu.context(focus_handle.clone())
1844 .header("Filter Options")
1845 .toggleable_entry(
1846 "Include Ignored Files",
1847 include_ignored.unwrap_or(false),
1848 ui::IconPosition::End,
1849 Some(ToggleIncludeIgnored.boxed_clone()),
1850 move |window, cx| {
1851 window.focus(&focus_handle, cx);
1852 window.dispatch_action(
1853 ToggleIncludeIgnored.boxed_clone(),
1854 cx,
1855 );
1856 },
1857 )
1858 }
1859 }))
1860 }
1861 }),
1862 )
1863 .child(
1864 h_flex()
1865 .gap_0p5()
1866 .child(
1867 PopoverMenu::new("split-menu-popover")
1868 .with_handle(self.split_popover_menu_handle.clone())
1869 .attach(gpui::Anchor::BottomRight)
1870 .anchor(gpui::Anchor::BottomLeft)
1871 .offset(gpui::Point {
1872 x: px(1.0),
1873 y: px(1.0),
1874 })
1875 .trigger(
1876 ButtonLike::new("split-trigger")
1877 .child(Label::new("Split…"))
1878 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1879 .child(
1880 KeyBinding::for_action_in(
1881 &ToggleSplitMenu,
1882 &focus_handle,
1883 cx,
1884 )
1885 .size(rems_from_px(12.)),
1886 ),
1887 )
1888 .menu({
1889 let focus_handle = focus_handle.clone();
1890
1891 move |window, cx| {
1892 Some(ContextMenu::build(window, cx, {
1893 let focus_handle = focus_handle.clone();
1894 move |menu, _, _| {
1895 menu.context(focus_handle)
1896 .action(
1897 "Split Left",
1898 pane::SplitLeft::default().boxed_clone(),
1899 )
1900 .action(
1901 "Split Right",
1902 pane::SplitRight::default().boxed_clone(),
1903 )
1904 .action(
1905 "Split Up",
1906 pane::SplitUp::default().boxed_clone(),
1907 )
1908 .action(
1909 "Split Down",
1910 pane::SplitDown::default().boxed_clone(),
1911 )
1912 }
1913 }))
1914 }
1915 }),
1916 )
1917 .child(
1918 Button::new("open-selection", "Open")
1919 .key_binding(
1920 KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
1921 .map(|kb| kb.size(rems_from_px(12.))),
1922 )
1923 .on_click(|_, window, cx| {
1924 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1925 }),
1926 ),
1927 )
1928 .into_any(),
1929 )
1930 }
1931}
1932
1933#[derive(Clone, Debug, PartialEq, Eq)]
1934struct PathComponentSlice<'a> {
1935 path: Cow<'a, Path>,
1936 path_str: Cow<'a, str>,
1937 component_ranges: Vec<(Component<'a>, Range<usize>)>,
1938}
1939
1940impl<'a> PathComponentSlice<'a> {
1941 fn new(path: &'a str) -> Self {
1942 let trimmed_path = Path::new(path).components().as_path().as_os_str();
1943 let mut component_ranges = Vec::new();
1944 let mut components = Path::new(trimmed_path).components();
1945 let len = trimmed_path.as_encoded_bytes().len();
1946 let mut pos = 0;
1947 while let Some(component) = components.next() {
1948 component_ranges.push((component, pos..0));
1949 pos = len - components.as_path().as_os_str().as_encoded_bytes().len();
1950 }
1951 for ((_, range), ancestor) in component_ranges
1952 .iter_mut()
1953 .rev()
1954 .zip(Path::new(trimmed_path).ancestors())
1955 {
1956 range.end = ancestor.as_os_str().as_encoded_bytes().len();
1957 }
1958 Self {
1959 path: Cow::Borrowed(Path::new(path)),
1960 path_str: Cow::Borrowed(path),
1961 component_ranges,
1962 }
1963 }
1964
1965 fn elision_range(&self, budget: usize, matches: &[usize]) -> Option<Range<usize>> {
1966 let eligible_range = {
1967 assert!(matches.windows(2).all(|w| w[0] <= w[1]));
1968 let mut matches = matches.iter().copied().peekable();
1969 let mut longest: Option<Range<usize>> = None;
1970 let mut cur = 0..0;
1971 let mut seen_normal = false;
1972 for (i, (component, range)) in self.component_ranges.iter().enumerate() {
1973 let is_normal = matches!(component, Component::Normal(_));
1974 let is_first_normal = is_normal && !seen_normal;
1975 seen_normal |= is_normal;
1976 let is_last = i == self.component_ranges.len() - 1;
1977 let contains_match = matches.peek().is_some_and(|mat| range.contains(mat));
1978 if contains_match {
1979 matches.next();
1980 }
1981 if is_first_normal || is_last || !is_normal || contains_match {
1982 if longest
1983 .as_ref()
1984 .is_none_or(|old| old.end - old.start <= cur.end - cur.start)
1985 {
1986 longest = Some(cur);
1987 }
1988 cur = i + 1..i + 1;
1989 } else {
1990 cur.end = i + 1;
1991 }
1992 }
1993 if longest
1994 .as_ref()
1995 .is_none_or(|old| old.end - old.start <= cur.end - cur.start)
1996 {
1997 longest = Some(cur);
1998 }
1999 longest
2000 };
2001
2002 let eligible_range = eligible_range?;
2003 assert!(eligible_range.start <= eligible_range.end);
2004 if eligible_range.is_empty() {
2005 return None;
2006 }
2007
2008 let elided_range: Range<usize> = {
2009 let byte_range = self.component_ranges[eligible_range.start].1.start
2010 ..self.component_ranges[eligible_range.end - 1].1.end;
2011 let midpoint = self.path_str.len() / 2;
2012 let distance_from_start = byte_range.start.abs_diff(midpoint);
2013 let distance_from_end = byte_range.end.abs_diff(midpoint);
2014 let pick_from_end = distance_from_start > distance_from_end;
2015 let mut len_with_elision = self.path_str.len();
2016 let mut i = eligible_range.start;
2017 while i < eligible_range.end {
2018 let x = if pick_from_end {
2019 eligible_range.end - i + eligible_range.start - 1
2020 } else {
2021 i
2022 };
2023 len_with_elision -= self.component_ranges[x]
2024 .0
2025 .as_os_str()
2026 .as_encoded_bytes()
2027 .len()
2028 + 1;
2029 if len_with_elision <= budget {
2030 break;
2031 }
2032 i += 1;
2033 }
2034 if len_with_elision > budget {
2035 return None;
2036 } else if pick_from_end {
2037 let x = eligible_range.end - i + eligible_range.start - 1;
2038 x..eligible_range.end
2039 } else {
2040 let x = i;
2041 eligible_range.start..x + 1
2042 }
2043 };
2044
2045 let byte_range = self.component_ranges[elided_range.start].1.start
2046 ..self.component_ranges[elided_range.end - 1].1.end;
2047 Some(byte_range)
2048 }
2049}