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