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 // Only match history items names, otherwise their paths may match too many queries,
702 // producing false positives. E.g. `foo` would match both `something/foo/bar.rs` and
703 // `something/foo/foo.rs` and if the former is a history item, it would be shown first
704 // always, despite the latter being a better match.
705 let candidate = PathMatchCandidate::new(
706 &found_path.project.path,
707 false,
708 worktree_name_by_id
709 .as_ref()
710 .and_then(|m| m.get(&found_path.project.worktree_id))
711 .map(|prefix| prefix.as_ref()),
712 );
713 candidates_paths.insert(&found_path.project, found_path);
714 (found_path.project.worktree_id, candidate)
715 })
716 .fold(
717 HashMap::default(),
718 |mut candidates, (worktree_id, new_candidate)| {
719 candidates
720 .entry(worktree_id)
721 .or_insert_with(Vec::new)
722 .push(new_candidate);
723 candidates
724 },
725 );
726 let mut matching_history_paths = HashMap::default();
727 for (worktree, candidates) in history_items_by_worktrees {
728 let max_results = candidates.len() + 1;
729 let worktree_root_name = worktree_name_by_id
730 .as_ref()
731 .and_then(|w| w.get(&worktree).cloned());
732
733 matching_history_paths.extend(
734 fuzzy_nucleo::match_fixed_path_set(
735 candidates,
736 worktree.to_usize(),
737 worktree_root_name,
738 query.path_query(),
739 fuzzy_nucleo::Case::Ignore,
740 max_results,
741 path_style,
742 )
743 .into_iter()
744 // 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
745 .filter(|path_match| {
746 if let Some(filename) = path_match.path.file_name() {
747 let filename_start = path_match.path.as_unix_str().len() - filename.len();
748 path_match
749 .positions
750 .iter()
751 .any(|&pos| pos >= filename_start)
752 } else {
753 true
754 }
755 })
756 .filter_map(|path_match| {
757 candidates_paths
758 .remove_entry(&ProjectPath {
759 worktree_id: WorktreeId::from_usize(path_match.worktree_id),
760 path: Arc::clone(&path_match.path),
761 })
762 .map(|(project_path, found_path)| {
763 (
764 project_path.clone(),
765 Match::History {
766 path: found_path.clone(),
767 panel_match: Some(ProjectPanelOrdMatch(path_match)),
768 },
769 )
770 })
771 }),
772 );
773 }
774 matching_history_paths
775}
776
777fn should_hide_root_in_entry_path(worktree_store: &Entity<WorktreeStore>, cx: &App) -> bool {
778 let multiple_worktrees = worktree_store
779 .read(cx)
780 .visible_worktrees(cx)
781 .filter(|worktree| !worktree.read(cx).is_single_file())
782 .nth(1)
783 .is_some();
784 ProjectPanelSettings::get_global(cx).hide_root && !multiple_worktrees
785}
786
787#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
788struct FoundPath {
789 project: ProjectPath,
790 absolute: PathBuf,
791}
792
793impl FoundPath {
794 fn new(project: ProjectPath, absolute: PathBuf) -> Self {
795 Self { project, absolute }
796 }
797}
798
799const MAX_RECENT_SELECTIONS: usize = 20;
800
801pub enum Event {
802 Selected(ProjectPath),
803 Dismissed,
804}
805
806#[derive(Debug, Clone)]
807struct FileSearchQuery {
808 raw_query: String,
809 file_query_end: Option<usize>,
810 path_position: PathWithPosition,
811}
812
813impl FileSearchQuery {
814 fn path_query(&self) -> &str {
815 match self.file_query_end {
816 Some(file_path_end) => &self.raw_query[..file_path_end],
817 None => &self.raw_query,
818 }
819 }
820}
821
822impl FileFinderDelegate {
823 fn new(
824 file_finder: WeakEntity<FileFinder>,
825 workspace: WeakEntity<Workspace>,
826 project: Entity<Project>,
827 currently_opened_path: Option<FoundPath>,
828 history_items: Vec<FoundPath>,
829 separate_history: bool,
830 window: &mut Window,
831 cx: &mut Context<FileFinder>,
832 ) -> Self {
833 Self::subscribe_to_updates(&project, window, cx);
834 let channel_store = if FileFinderSettings::get_global(cx).include_channels {
835 ChannelStore::try_global(cx)
836 } else {
837 None
838 };
839 Self {
840 file_finder,
841 workspace,
842 project,
843 channel_store,
844 search_count: 0,
845 latest_search_id: 0,
846 latest_search_did_cancel: false,
847 latest_search_query: None,
848 currently_opened_path,
849 matches: Matches::default(),
850 has_changed_selected_index: false,
851 selected_index: 0,
852 cancel_flag: Arc::new(AtomicBool::new(false)),
853 history_items,
854 separate_history,
855 first_update: true,
856 filter_popover_menu_handle: PopoverMenuHandle::default(),
857 split_popover_menu_handle: PopoverMenuHandle::default(),
858 focus_handle: cx.focus_handle(),
859 include_ignored: FileFinderSettings::get_global(cx).include_ignored,
860 include_ignored_refresh: Task::ready(()),
861 }
862 }
863
864 fn subscribe_to_updates(
865 project: &Entity<Project>,
866 window: &mut Window,
867 cx: &mut Context<FileFinder>,
868 ) {
869 cx.subscribe_in(project, window, |file_finder, _, event, window, cx| {
870 match event {
871 project::Event::WorktreeUpdatedEntries(_, _)
872 | project::Event::WorktreeAdded(_)
873 | project::Event::WorktreeRemoved(_) => file_finder
874 .picker
875 .update(cx, |picker, cx| picker.refresh(window, cx)),
876 _ => {}
877 };
878 })
879 .detach();
880 }
881
882 fn spawn_search(
883 &mut self,
884 query: FileSearchQuery,
885 window: &mut Window,
886 cx: &mut Context<Picker<Self>>,
887 ) -> Task<()> {
888 let relative_to = self
889 .currently_opened_path
890 .as_ref()
891 .map(|found_path| Arc::clone(&found_path.project.path));
892 let worktree_store = self.project.read(cx).worktree_store();
893 let worktrees = worktree_store
894 .read(cx)
895 .visible_worktrees_and_single_files(cx)
896 .collect::<Vec<_>>();
897 let include_root_name = !should_hide_root_in_entry_path(&worktree_store, cx);
898 let candidate_sets = worktrees
899 .into_iter()
900 .map(|worktree| {
901 let worktree = worktree.read(cx);
902 PathMatchCandidateSet {
903 snapshot: worktree.snapshot(),
904 include_ignored: self.include_ignored.unwrap_or_else(|| {
905 worktree.root_entry().is_some_and(|entry| entry.is_ignored)
906 }),
907 include_root_name,
908 candidates: project::Candidates::Files,
909 }
910 })
911 .collect::<Vec<_>>();
912
913 let search_id = util::post_inc(&mut self.search_count);
914 self.cancel_flag.store(true, atomic::Ordering::Release);
915 self.cancel_flag = Arc::new(AtomicBool::new(false));
916 let cancel_flag = self.cancel_flag.clone();
917 cx.spawn_in(window, async move |picker, cx| {
918 let matches = fuzzy_nucleo::match_path_sets(
919 candidate_sets.as_slice(),
920 query.path_query(),
921 &relative_to,
922 fuzzy_nucleo::Case::Ignore,
923 100,
924 &cancel_flag,
925 cx.background_executor().clone(),
926 )
927 .await
928 .into_iter()
929 .map(ProjectPanelOrdMatch);
930 let did_cancel = cancel_flag.load(atomic::Ordering::Acquire);
931 picker
932 .update(cx, |picker, cx| {
933 picker
934 .delegate
935 .set_search_matches(search_id, did_cancel, query, matches, cx)
936 })
937 .log_err();
938 })
939 }
940
941 fn set_search_matches(
942 &mut self,
943 search_id: usize,
944 did_cancel: bool,
945 query: FileSearchQuery,
946 matches: impl IntoIterator<Item = ProjectPanelOrdMatch>,
947 cx: &mut Context<Picker<Self>>,
948 ) {
949 if search_id >= self.latest_search_id {
950 self.latest_search_id = search_id;
951 let query_changed = Some(query.path_query())
952 != self
953 .latest_search_query
954 .as_ref()
955 .map(|query| query.path_query());
956 let extend_old_matches = self.latest_search_did_cancel && !query_changed;
957
958 let selected_match = if query_changed {
959 None
960 } else {
961 self.matches.get(self.selected_index).cloned()
962 };
963
964 let path_style = self.project.read(cx).path_style(cx);
965 self.matches.push_new_matches(
966 self.project.read(cx).worktree_store(),
967 cx,
968 &self.history_items,
969 self.currently_opened_path.as_ref(),
970 Some(&query),
971 matches.into_iter(),
972 extend_old_matches,
973 path_style,
974 );
975
976 // Add channel matches
977 if let Some(channel_store) = &self.channel_store {
978 let channel_store = channel_store.read(cx);
979 let channels: Vec<_> = channel_store.channels().cloned().collect();
980 if !channels.is_empty() {
981 let candidates = channels
982 .iter()
983 .enumerate()
984 .map(|(id, channel)| StringMatchCandidate::new(id, &channel.name));
985 let channel_query = query.path_query();
986 let query_lower = channel_query.to_lowercase();
987 let mut channel_matches = Vec::new();
988 for candidate in candidates {
989 let channel_name = candidate.string;
990 let name_lower = channel_name.to_lowercase();
991
992 let mut positions = Vec::new();
993 let mut query_idx = 0;
994 for (name_idx, name_char) in name_lower.char_indices() {
995 if query_idx < query_lower.len() {
996 let query_char =
997 query_lower[query_idx..].chars().next().unwrap_or_default();
998 if name_char == query_char {
999 positions.push(name_idx);
1000 query_idx += query_char.len_utf8();
1001 }
1002 }
1003 }
1004
1005 if query_idx == query_lower.len() {
1006 let channel = &channels[candidate.id];
1007 let score = if name_lower == query_lower {
1008 1.0
1009 } else if name_lower.starts_with(&query_lower) {
1010 0.8
1011 } else {
1012 0.5 * (query_lower.len() as f64 / name_lower.len() as f64)
1013 };
1014 channel_matches.push(Match::Channel {
1015 channel_id: channel.id,
1016 channel_name: channel.name.clone(),
1017 string_match: StringMatch {
1018 candidate_id: candidate.id,
1019 score,
1020 positions,
1021 string: channel_name,
1022 },
1023 });
1024 }
1025 }
1026 for channel_match in channel_matches {
1027 match self
1028 .matches
1029 .position(&channel_match, self.currently_opened_path.as_ref())
1030 {
1031 Ok(_duplicate) => {}
1032 Err(ix) => self.matches.matches.insert(ix, channel_match),
1033 }
1034 }
1035 }
1036 }
1037
1038 let query_path = query.raw_query.as_str();
1039 if let Ok(mut query_path) = RelPath::new(Path::new(query_path), path_style) {
1040 let available_worktree = self
1041 .project
1042 .read(cx)
1043 .visible_worktrees(cx)
1044 .filter(|worktree| !worktree.read(cx).is_single_file())
1045 .collect::<Vec<_>>();
1046 let worktree_count = available_worktree.len();
1047 let mut expect_worktree = available_worktree.first().cloned();
1048 for worktree in &available_worktree {
1049 let worktree_root = worktree.read(cx).root_name();
1050 if worktree_count > 1 {
1051 if let Ok(suffix) = query_path.strip_prefix(worktree_root) {
1052 query_path = Cow::Owned(suffix.to_owned());
1053 expect_worktree = Some(worktree.clone());
1054 break;
1055 }
1056 }
1057 }
1058
1059 if let Some(FoundPath { ref project, .. }) = self.currently_opened_path {
1060 let worktree_id = project.worktree_id;
1061 let focused_file_in_available_worktree = available_worktree
1062 .iter()
1063 .any(|wt| wt.read(cx).id() == worktree_id);
1064
1065 if focused_file_in_available_worktree {
1066 expect_worktree = self.project.read(cx).worktree_for_id(worktree_id, cx);
1067 }
1068 }
1069
1070 if let Some(worktree) = expect_worktree {
1071 let worktree = worktree.read(cx);
1072 if worktree.entry_for_path(&query_path).is_none()
1073 && !query.raw_query.ends_with("/")
1074 && !(path_style.is_windows() && query.raw_query.ends_with("\\"))
1075 {
1076 self.matches.matches.push(Match::CreateNew(ProjectPath {
1077 worktree_id: worktree.id(),
1078 path: query_path.into_arc(),
1079 }));
1080 }
1081 }
1082 }
1083
1084 self.selected_index = selected_match.map_or_else(
1085 || self.calculate_selected_index(cx),
1086 |m| {
1087 self.matches
1088 .position(&m, self.currently_opened_path.as_ref())
1089 .unwrap_or(0)
1090 },
1091 );
1092
1093 self.latest_search_query = Some(query);
1094 self.latest_search_did_cancel = did_cancel;
1095
1096 cx.notify();
1097 }
1098 }
1099
1100 fn labels_for_match(
1101 &self,
1102 path_match: &Match,
1103 window: &mut Window,
1104 cx: &App,
1105 ) -> (HighlightedLabel, HighlightedLabel) {
1106 let path_style = self.project.read(cx).path_style(cx);
1107 let (file_name, file_name_positions, mut full_path, mut full_path_positions) =
1108 match &path_match {
1109 Match::History {
1110 path: entry_path,
1111 panel_match,
1112 } => {
1113 let worktree_id = entry_path.project.worktree_id;
1114 let worktree = self
1115 .project
1116 .read(cx)
1117 .worktree_for_id(worktree_id, cx)
1118 .filter(|worktree| worktree.read(cx).is_visible());
1119
1120 if let Some(panel_match) = panel_match {
1121 self.labels_for_path_match(&panel_match.0, path_style)
1122 } else if let Some(worktree) = worktree {
1123 let worktree_store = self.project.read(cx).worktree_store();
1124 let full_path = if should_hide_root_in_entry_path(&worktree_store, cx) {
1125 entry_path.project.path.clone()
1126 } else {
1127 worktree.read(cx).root_name().join(&entry_path.project.path)
1128 };
1129 let mut components = full_path.components();
1130 let filename = components.next_back().unwrap_or("");
1131 let prefix = components.rest();
1132 (
1133 filename.to_string(),
1134 Vec::new(),
1135 prefix.display(path_style).to_string() + path_style.primary_separator(),
1136 Vec::new(),
1137 )
1138 } else {
1139 (
1140 entry_path
1141 .absolute
1142 .file_name()
1143 .map_or(String::new(), |f| f.to_string_lossy().into_owned()),
1144 Vec::new(),
1145 entry_path.absolute.parent().map_or(String::new(), |path| {
1146 path.to_string_lossy().into_owned() + path_style.primary_separator()
1147 }),
1148 Vec::new(),
1149 )
1150 }
1151 }
1152 Match::Search(path_match) => self.labels_for_path_match(&path_match.0, path_style),
1153 Match::Channel {
1154 channel_name,
1155 string_match,
1156 ..
1157 } => (
1158 channel_name.to_string(),
1159 string_match.positions.clone(),
1160 "Channel Notes".to_string(),
1161 vec![],
1162 ),
1163 Match::CreateNew(project_path) => (
1164 format!("Create file: {}", project_path.path.display(path_style)),
1165 vec![],
1166 String::from(""),
1167 vec![],
1168 ),
1169 };
1170
1171 if file_name_positions.is_empty() {
1172 let user_home_path = util::paths::home_dir().to_string_lossy();
1173 if !user_home_path.is_empty() && full_path.starts_with(&*user_home_path) {
1174 full_path.replace_range(0..user_home_path.len(), "~");
1175 full_path_positions.retain_mut(|pos| {
1176 if *pos >= user_home_path.len() {
1177 *pos -= user_home_path.len();
1178 *pos += 1;
1179 true
1180 } else {
1181 false
1182 }
1183 })
1184 }
1185 }
1186
1187 if full_path.is_ascii() {
1188 let file_finder_settings = FileFinderSettings::get_global(cx);
1189 let max_width =
1190 FileFinder::modal_max_width(file_finder_settings.modal_max_width, window);
1191 let (normal_em, small_em) = {
1192 let style = window.text_style();
1193 let font_id = window.text_system().resolve_font(&style.font());
1194 let font_size = TextSize::Default.rems(cx).to_pixels(window.rem_size());
1195 let normal = cx
1196 .text_system()
1197 .em_width(font_id, font_size)
1198 .unwrap_or(px(16.));
1199 let font_size = TextSize::Small.rems(cx).to_pixels(window.rem_size());
1200 let small = cx
1201 .text_system()
1202 .em_width(font_id, font_size)
1203 .unwrap_or(px(10.));
1204 (normal, small)
1205 };
1206 let budget = full_path_budget(&file_name, normal_em, small_em, max_width);
1207 // If the computed budget is zero, we certainly won't be able to achieve it,
1208 // so no point trying to elide the path.
1209 if budget > 0 && full_path.len() > budget {
1210 let components = PathComponentSlice::new(&full_path);
1211 if let Some(elided_range) =
1212 components.elision_range(budget - 1, &full_path_positions)
1213 {
1214 let elided_len = elided_range.end - elided_range.start;
1215 let placeholder = "…";
1216 full_path_positions.retain_mut(|mat| {
1217 if *mat >= elided_range.end {
1218 *mat -= elided_len;
1219 *mat += placeholder.len();
1220 } else if *mat >= elided_range.start {
1221 return false;
1222 }
1223 true
1224 });
1225 full_path.replace_range(elided_range, placeholder);
1226 }
1227 }
1228 }
1229
1230 (
1231 HighlightedLabel::new(file_name, file_name_positions),
1232 HighlightedLabel::new(full_path, full_path_positions)
1233 .size(LabelSize::Small)
1234 .color(Color::Muted),
1235 )
1236 }
1237
1238 fn labels_for_path_match(
1239 &self,
1240 path_match: &PathMatch,
1241 path_style: PathStyle,
1242 ) -> (String, Vec<usize>, String, Vec<usize>) {
1243 let full_path = path_match.path_prefix.join(&path_match.path);
1244 let mut path_positions = path_match.positions.clone();
1245
1246 let file_name = full_path.file_name().unwrap_or("");
1247 let file_name_start = full_path.as_unix_str().len() - file_name.len();
1248 let file_name_positions = path_positions
1249 .iter()
1250 .filter_map(|pos| {
1251 if pos >= &file_name_start {
1252 Some(pos - file_name_start)
1253 } else {
1254 None
1255 }
1256 })
1257 .collect::<Vec<_>>();
1258
1259 let full_path = full_path
1260 .display(path_style)
1261 .trim_end_matches(&file_name)
1262 .to_string();
1263 path_positions.retain(|idx| *idx < full_path.len());
1264
1265 debug_assert!(
1266 file_name_positions
1267 .iter()
1268 .all(|ix| file_name[*ix..].chars().next().is_some()),
1269 "invalid file name positions {file_name:?} {file_name_positions:?}"
1270 );
1271 debug_assert!(
1272 path_positions
1273 .iter()
1274 .all(|ix| full_path[*ix..].chars().next().is_some()),
1275 "invalid path positions {full_path:?} {path_positions:?}"
1276 );
1277
1278 (
1279 file_name.to_string(),
1280 file_name_positions,
1281 full_path,
1282 path_positions,
1283 )
1284 }
1285
1286 /// Attempts to resolve an absolute file path and update the search matches if found.
1287 ///
1288 /// If the query path resolves to an absolute file that exists in the project,
1289 /// this method will find the corresponding worktree and relative path, create a
1290 /// match for it, and update the picker's search results.
1291 ///
1292 /// Returns `true` if the absolute path exists, otherwise returns `false`.
1293 fn lookup_absolute_path(
1294 &self,
1295 query: FileSearchQuery,
1296 window: &mut Window,
1297 cx: &mut Context<Picker<Self>>,
1298 ) -> Task<bool> {
1299 cx.spawn_in(window, async move |picker, cx| {
1300 let Some(project) = picker
1301 .read_with(cx, |picker, _| picker.delegate.project.clone())
1302 .log_err()
1303 else {
1304 return false;
1305 };
1306
1307 let query_path = Path::new(query.path_query());
1308 let mut path_matches = Vec::new();
1309
1310 let abs_file_exists = project
1311 .update(cx, |this, cx| {
1312 this.resolve_abs_file_path(query.path_query(), cx)
1313 })
1314 .await
1315 .is_some();
1316
1317 if abs_file_exists {
1318 project.update(cx, |project, cx| {
1319 if let Some((worktree, relative_path)) = project.find_worktree(query_path, cx) {
1320 path_matches.push(ProjectPanelOrdMatch(PathMatch {
1321 score: 1.0,
1322 positions: Vec::new(),
1323 worktree_id: worktree.read(cx).id().to_usize(),
1324 path: relative_path,
1325 path_prefix: RelPath::empty().into(),
1326 is_dir: false, // File finder doesn't support directories
1327 distance_to_relative_ancestor: usize::MAX,
1328 }));
1329 }
1330 });
1331 }
1332
1333 picker
1334 .update_in(cx, |picker, _, cx| {
1335 let picker_delegate = &mut picker.delegate;
1336 let search_id = util::post_inc(&mut picker_delegate.search_count);
1337 picker_delegate.set_search_matches(search_id, false, query, path_matches, cx);
1338
1339 anyhow::Ok(())
1340 })
1341 .log_err();
1342 abs_file_exists
1343 })
1344 }
1345
1346 /// Skips first history match (that is displayed topmost) if it's currently opened.
1347 fn calculate_selected_index(&self, cx: &mut Context<Picker<Self>>) -> usize {
1348 if FileFinderSettings::get_global(cx).skip_focus_for_active_in_search
1349 && let Some(Match::History { path, .. }) = self.matches.get(0)
1350 && Some(path) == self.currently_opened_path.as_ref()
1351 {
1352 let elements_after_first = self.matches.len() - 1;
1353 if elements_after_first > 0 {
1354 return 1;
1355 }
1356 }
1357
1358 0
1359 }
1360
1361 fn key_context(&self, window: &Window, cx: &App) -> KeyContext {
1362 let mut key_context = KeyContext::new_with_defaults();
1363 key_context.add("FileFinder");
1364
1365 if self.filter_popover_menu_handle.is_focused(window, cx) {
1366 key_context.add("filter_menu_open");
1367 }
1368
1369 if self.split_popover_menu_handle.is_focused(window, cx) {
1370 key_context.add("split_menu_open");
1371 }
1372 key_context
1373 }
1374}
1375
1376fn full_path_budget(
1377 file_name: &str,
1378 normal_em: Pixels,
1379 small_em: Pixels,
1380 max_width: Pixels,
1381) -> usize {
1382 (((max_width / 0.8) - file_name.len() * normal_em) / small_em) as usize
1383}
1384
1385impl PickerDelegate for FileFinderDelegate {
1386 type ListItem = ListItem;
1387
1388 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
1389 "Search project files...".into()
1390 }
1391
1392 fn match_count(&self) -> usize {
1393 self.matches.len()
1394 }
1395
1396 fn selected_index(&self) -> usize {
1397 self.selected_index
1398 }
1399
1400 fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
1401 self.has_changed_selected_index = true;
1402 self.selected_index = ix;
1403 cx.notify();
1404 }
1405
1406 fn separators_after_indices(&self) -> Vec<usize> {
1407 if self.separate_history {
1408 let first_non_history_index = self
1409 .matches
1410 .matches
1411 .iter()
1412 .enumerate()
1413 .find(|(_, m)| !matches!(m, Match::History { .. }))
1414 .map(|(i, _)| i);
1415 if let Some(first_non_history_index) = first_non_history_index
1416 && first_non_history_index > 0
1417 {
1418 return vec![first_non_history_index - 1];
1419 }
1420 }
1421 Vec::new()
1422 }
1423
1424 fn update_matches(
1425 &mut self,
1426 raw_query: String,
1427 window: &mut Window,
1428 cx: &mut Context<Picker<Self>>,
1429 ) -> Task<()> {
1430 let raw_query = raw_query.trim();
1431
1432 let raw_query = match &raw_query.get(0..2) {
1433 Some(".\\" | "./") => &raw_query[2..],
1434 Some(prefix @ ("a\\" | "a/" | "b\\" | "b/")) => {
1435 if self
1436 .workspace
1437 .upgrade()
1438 .into_iter()
1439 .flat_map(|workspace| workspace.read(cx).worktrees(cx))
1440 .all(|worktree| {
1441 worktree
1442 .read(cx)
1443 .entry_for_path(RelPath::unix(prefix.split_at(1).0).unwrap())
1444 .is_none_or(|entry| !entry.is_dir())
1445 })
1446 {
1447 &raw_query[2..]
1448 } else {
1449 raw_query
1450 }
1451 }
1452 _ => raw_query,
1453 };
1454
1455 if raw_query.is_empty() {
1456 // if there was no query before, and we already have some (history) matches
1457 // there's no need to update anything, since nothing has changed.
1458 // We also want to populate matches set from history entries on the first update.
1459 if self.latest_search_query.is_some() || self.first_update {
1460 let project = self.project.read(cx);
1461
1462 self.latest_search_id = post_inc(&mut self.search_count);
1463 self.latest_search_query = None;
1464 self.matches = Matches {
1465 separate_history: self.separate_history,
1466 ..Matches::default()
1467 };
1468 let path_style = self.project.read(cx).path_style(cx);
1469
1470 self.matches.push_new_matches(
1471 project.worktree_store(),
1472 cx,
1473 self.history_items.iter().filter(|history_item| {
1474 project
1475 .worktree_for_id(history_item.project.worktree_id, cx)
1476 .is_some()
1477 || project.is_local()
1478 || project.is_via_remote_server()
1479 }),
1480 self.currently_opened_path.as_ref(),
1481 None,
1482 None.into_iter(),
1483 false,
1484 path_style,
1485 );
1486
1487 self.first_update = false;
1488 self.selected_index = 0;
1489 }
1490 cx.notify();
1491 Task::ready(())
1492 } else {
1493 let path_position = PathWithPosition::parse_str(raw_query);
1494 let raw_query = raw_query.trim().trim_end_matches(':').to_owned();
1495 let path = path_position.path.clone();
1496 let path_str = path_position.path.to_str();
1497 let path_trimmed = path_str.unwrap_or(&raw_query).trim_end_matches(':');
1498 let file_query_end = if path_trimmed == raw_query {
1499 None
1500 } else {
1501 // Safe to unwrap as we won't get here when the unwrap in if fails
1502 Some(path_str.unwrap().len())
1503 };
1504
1505 let query = FileSearchQuery {
1506 raw_query,
1507 file_query_end,
1508 path_position,
1509 };
1510
1511 cx.spawn_in(window, async move |this, cx| {
1512 let _ = maybe!(async move {
1513 let is_absolute_path = path.is_absolute();
1514 let did_resolve_abs_path = is_absolute_path
1515 && this
1516 .update_in(cx, |this, window, cx| {
1517 this.delegate
1518 .lookup_absolute_path(query.clone(), window, cx)
1519 })?
1520 .await;
1521
1522 // Only check for relative paths if no absolute paths were
1523 // found.
1524 if !did_resolve_abs_path {
1525 this.update_in(cx, |this, window, cx| {
1526 this.delegate.spawn_search(query, window, cx)
1527 })?
1528 .await;
1529 }
1530 anyhow::Ok(())
1531 })
1532 .await;
1533 })
1534 }
1535 }
1536
1537 fn confirm(
1538 &mut self,
1539 secondary: bool,
1540 window: &mut Window,
1541 cx: &mut Context<Picker<FileFinderDelegate>>,
1542 ) {
1543 if let Some(m) = self.matches.get(self.selected_index())
1544 && let Some(workspace) = self.workspace.upgrade()
1545 {
1546 // Channel matches are handled separately since they dispatch an action
1547 // rather than directly opening a file path.
1548 if let Match::Channel { channel_id, .. } = m {
1549 let channel_id = channel_id.0;
1550 let finder = self.file_finder.clone();
1551 window.dispatch_action(OpenChannelNotesById { channel_id }.boxed_clone(), cx);
1552 finder.update(cx, |_, cx| cx.emit(DismissEvent)).log_err();
1553 return;
1554 }
1555
1556 let open_task = workspace.update(cx, |workspace, cx| {
1557 let split_or_open =
1558 |workspace: &mut Workspace,
1559 project_path,
1560 window: &mut Window,
1561 cx: &mut Context<Workspace>| {
1562 let allow_preview =
1563 PreviewTabsSettings::get_global(cx).enable_preview_from_file_finder;
1564 if secondary {
1565 workspace.split_path_preview(
1566 project_path,
1567 allow_preview,
1568 None,
1569 window,
1570 cx,
1571 )
1572 } else {
1573 workspace.open_path_preview(
1574 project_path,
1575 None,
1576 true,
1577 allow_preview,
1578 true,
1579 window,
1580 cx,
1581 )
1582 }
1583 };
1584 match &m {
1585 Match::CreateNew(project_path) => {
1586 // Create a new file with the given filename
1587 if secondary {
1588 workspace.split_path_preview(
1589 project_path.clone(),
1590 false,
1591 None,
1592 window,
1593 cx,
1594 )
1595 } else {
1596 workspace.open_path_preview(
1597 project_path.clone(),
1598 None,
1599 true,
1600 false,
1601 true,
1602 window,
1603 cx,
1604 )
1605 }
1606 }
1607
1608 Match::History { path, .. } => {
1609 let worktree_id = path.project.worktree_id;
1610 if workspace
1611 .project()
1612 .read(cx)
1613 .worktree_for_id(worktree_id, cx)
1614 .is_some()
1615 {
1616 split_or_open(
1617 workspace,
1618 ProjectPath {
1619 worktree_id,
1620 path: Arc::clone(&path.project.path),
1621 },
1622 window,
1623 cx,
1624 )
1625 } else if secondary {
1626 workspace.split_abs_path(path.absolute.clone(), false, window, cx)
1627 } else {
1628 workspace.open_abs_path(
1629 path.absolute.clone(),
1630 OpenOptions {
1631 visible: Some(OpenVisible::None),
1632 ..Default::default()
1633 },
1634 window,
1635 cx,
1636 )
1637 }
1638 }
1639 Match::Search(m) => split_or_open(
1640 workspace,
1641 ProjectPath {
1642 worktree_id: WorktreeId::from_usize(m.0.worktree_id),
1643 path: m.0.path.clone(),
1644 },
1645 window,
1646 cx,
1647 ),
1648 Match::Channel { .. } => unreachable!("handled above"),
1649 }
1650 });
1651
1652 let row = self
1653 .latest_search_query
1654 .as_ref()
1655 .and_then(|query| query.path_position.row)
1656 .map(|row| row.saturating_sub(1));
1657 let col = self
1658 .latest_search_query
1659 .as_ref()
1660 .and_then(|query| query.path_position.column)
1661 .unwrap_or(0)
1662 .saturating_sub(1);
1663 let finder = self.file_finder.clone();
1664 let workspace = self.workspace.clone();
1665
1666 cx.spawn_in(window, async move |_, mut cx| {
1667 let item = open_task
1668 .await
1669 .notify_workspace_async_err(workspace, &mut cx)?;
1670 if let Some(row) = row
1671 && let Some(active_editor) = item.downcast::<Editor>()
1672 {
1673 active_editor
1674 .downgrade()
1675 .update_in(cx, |editor, window, cx| {
1676 let Some(buffer) = editor.buffer().read(cx).as_singleton() else {
1677 return;
1678 };
1679 let buffer_snapshot = buffer.read(cx).snapshot();
1680 let point = buffer_snapshot.point_from_external_input(row, col);
1681 editor.go_to_singleton_buffer_point(point, window, cx);
1682 })
1683 .log_err();
1684 }
1685 finder.update(cx, |_, cx| cx.emit(DismissEvent)).ok()?;
1686
1687 Some(())
1688 })
1689 .detach();
1690 }
1691 }
1692
1693 fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<FileFinderDelegate>>) {
1694 self.file_finder
1695 .update(cx, |_, cx| cx.emit(DismissEvent))
1696 .log_err();
1697 }
1698
1699 fn render_match(
1700 &self,
1701 ix: usize,
1702 selected: bool,
1703 window: &mut Window,
1704 cx: &mut Context<Picker<Self>>,
1705 ) -> Option<Self::ListItem> {
1706 let settings = FileFinderSettings::get_global(cx);
1707
1708 let path_match = self.matches.get(ix)?;
1709
1710 let end_icon = match path_match {
1711 Match::History { .. } => Icon::new(IconName::HistoryRerun)
1712 .color(Color::Muted)
1713 .size(IconSize::Small)
1714 .into_any_element(),
1715 Match::Search(_) => v_flex()
1716 .flex_none()
1717 .size(IconSize::Small.rems())
1718 .into_any_element(),
1719 Match::Channel { .. } => v_flex()
1720 .flex_none()
1721 .size(IconSize::Small.rems())
1722 .into_any_element(),
1723 Match::CreateNew(_) => Icon::new(IconName::Plus)
1724 .color(Color::Muted)
1725 .size(IconSize::Small)
1726 .into_any_element(),
1727 };
1728 let (file_name_label, full_path_label) = self.labels_for_match(path_match, window, cx);
1729
1730 let file_icon = match path_match {
1731 Match::Channel { .. } => Some(Icon::new(IconName::Hash).color(Color::Muted)),
1732 _ => maybe!({
1733 if !settings.file_icons {
1734 return None;
1735 }
1736 let abs_path = path_match.abs_path(&self.project, cx)?;
1737 let file_name = abs_path.file_name()?;
1738 let icon = FileIcons::get_icon(file_name.as_ref(), cx)?;
1739 Some(Icon::from_path(icon).color(Color::Muted))
1740 }),
1741 };
1742
1743 Some(
1744 ListItem::new(ix)
1745 .spacing(ListItemSpacing::Sparse)
1746 .start_slot::<Icon>(file_icon)
1747 .end_slot::<AnyElement>(end_icon)
1748 .inset(true)
1749 .toggle_state(selected)
1750 .child(
1751 h_flex()
1752 .gap_2()
1753 .py_px()
1754 .child(file_name_label)
1755 .child(full_path_label),
1756 ),
1757 )
1758 }
1759
1760 fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
1761 let focus_handle = self.focus_handle.clone();
1762
1763 Some(
1764 h_flex()
1765 .w_full()
1766 .p_1p5()
1767 .justify_between()
1768 .border_t_1()
1769 .border_color(cx.theme().colors().border_variant)
1770 .child(
1771 PopoverMenu::new("filter-menu-popover")
1772 .with_handle(self.filter_popover_menu_handle.clone())
1773 .attach(gpui::Anchor::BottomRight)
1774 .anchor(gpui::Anchor::BottomLeft)
1775 .offset(gpui::Point {
1776 x: px(1.0),
1777 y: px(1.0),
1778 })
1779 .trigger_with_tooltip(
1780 IconButton::new("filter-trigger", IconName::Sliders)
1781 .icon_size(IconSize::Small)
1782 .icon_size(IconSize::Small)
1783 .toggle_state(self.include_ignored.unwrap_or(false))
1784 .when(self.include_ignored.is_some(), |this| {
1785 this.indicator(Indicator::dot().color(Color::Info))
1786 }),
1787 {
1788 let focus_handle = focus_handle.clone();
1789 move |_window, cx| {
1790 Tooltip::for_action_in(
1791 "Filter Options",
1792 &ToggleFilterMenu,
1793 &focus_handle,
1794 cx,
1795 )
1796 }
1797 },
1798 )
1799 .menu({
1800 let focus_handle = focus_handle.clone();
1801 let include_ignored = self.include_ignored;
1802
1803 move |window, cx| {
1804 Some(ContextMenu::build(window, cx, {
1805 let focus_handle = focus_handle.clone();
1806 move |menu, _, _| {
1807 menu.context(focus_handle.clone())
1808 .header("Filter Options")
1809 .toggleable_entry(
1810 "Include Ignored Files",
1811 include_ignored.unwrap_or(false),
1812 ui::IconPosition::End,
1813 Some(ToggleIncludeIgnored.boxed_clone()),
1814 move |window, cx| {
1815 window.focus(&focus_handle, cx);
1816 window.dispatch_action(
1817 ToggleIncludeIgnored.boxed_clone(),
1818 cx,
1819 );
1820 },
1821 )
1822 }
1823 }))
1824 }
1825 }),
1826 )
1827 .child(
1828 h_flex()
1829 .gap_0p5()
1830 .child(
1831 PopoverMenu::new("split-menu-popover")
1832 .with_handle(self.split_popover_menu_handle.clone())
1833 .attach(gpui::Anchor::BottomRight)
1834 .anchor(gpui::Anchor::BottomLeft)
1835 .offset(gpui::Point {
1836 x: px(1.0),
1837 y: px(1.0),
1838 })
1839 .trigger(
1840 ButtonLike::new("split-trigger")
1841 .child(Label::new("Split…"))
1842 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1843 .child(
1844 KeyBinding::for_action_in(
1845 &ToggleSplitMenu,
1846 &focus_handle,
1847 cx,
1848 )
1849 .size(rems_from_px(12.)),
1850 ),
1851 )
1852 .menu({
1853 let focus_handle = focus_handle.clone();
1854
1855 move |window, cx| {
1856 Some(ContextMenu::build(window, cx, {
1857 let focus_handle = focus_handle.clone();
1858 move |menu, _, _| {
1859 menu.context(focus_handle)
1860 .action(
1861 "Split Left",
1862 pane::SplitLeft::default().boxed_clone(),
1863 )
1864 .action(
1865 "Split Right",
1866 pane::SplitRight::default().boxed_clone(),
1867 )
1868 .action(
1869 "Split Up",
1870 pane::SplitUp::default().boxed_clone(),
1871 )
1872 .action(
1873 "Split Down",
1874 pane::SplitDown::default().boxed_clone(),
1875 )
1876 }
1877 }))
1878 }
1879 }),
1880 )
1881 .child(
1882 Button::new("open-selection", "Open")
1883 .key_binding(
1884 KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
1885 .map(|kb| kb.size(rems_from_px(12.))),
1886 )
1887 .on_click(|_, window, cx| {
1888 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1889 }),
1890 ),
1891 )
1892 .into_any(),
1893 )
1894 }
1895}
1896
1897#[derive(Clone, Debug, PartialEq, Eq)]
1898struct PathComponentSlice<'a> {
1899 path: Cow<'a, Path>,
1900 path_str: Cow<'a, str>,
1901 component_ranges: Vec<(Component<'a>, Range<usize>)>,
1902}
1903
1904impl<'a> PathComponentSlice<'a> {
1905 fn new(path: &'a str) -> Self {
1906 let trimmed_path = Path::new(path).components().as_path().as_os_str();
1907 let mut component_ranges = Vec::new();
1908 let mut components = Path::new(trimmed_path).components();
1909 let len = trimmed_path.as_encoded_bytes().len();
1910 let mut pos = 0;
1911 while let Some(component) = components.next() {
1912 component_ranges.push((component, pos..0));
1913 pos = len - components.as_path().as_os_str().as_encoded_bytes().len();
1914 }
1915 for ((_, range), ancestor) in component_ranges
1916 .iter_mut()
1917 .rev()
1918 .zip(Path::new(trimmed_path).ancestors())
1919 {
1920 range.end = ancestor.as_os_str().as_encoded_bytes().len();
1921 }
1922 Self {
1923 path: Cow::Borrowed(Path::new(path)),
1924 path_str: Cow::Borrowed(path),
1925 component_ranges,
1926 }
1927 }
1928
1929 fn elision_range(&self, budget: usize, matches: &[usize]) -> Option<Range<usize>> {
1930 let eligible_range = {
1931 assert!(matches.windows(2).all(|w| w[0] <= w[1]));
1932 let mut matches = matches.iter().copied().peekable();
1933 let mut longest: Option<Range<usize>> = None;
1934 let mut cur = 0..0;
1935 let mut seen_normal = false;
1936 for (i, (component, range)) in self.component_ranges.iter().enumerate() {
1937 let is_normal = matches!(component, Component::Normal(_));
1938 let is_first_normal = is_normal && !seen_normal;
1939 seen_normal |= is_normal;
1940 let is_last = i == self.component_ranges.len() - 1;
1941 let contains_match = matches.peek().is_some_and(|mat| range.contains(mat));
1942 if contains_match {
1943 matches.next();
1944 }
1945 if is_first_normal || is_last || !is_normal || contains_match {
1946 if longest
1947 .as_ref()
1948 .is_none_or(|old| old.end - old.start <= cur.end - cur.start)
1949 {
1950 longest = Some(cur);
1951 }
1952 cur = i + 1..i + 1;
1953 } else {
1954 cur.end = i + 1;
1955 }
1956 }
1957 if longest
1958 .as_ref()
1959 .is_none_or(|old| old.end - old.start <= cur.end - cur.start)
1960 {
1961 longest = Some(cur);
1962 }
1963 longest
1964 };
1965
1966 let eligible_range = eligible_range?;
1967 assert!(eligible_range.start <= eligible_range.end);
1968 if eligible_range.is_empty() {
1969 return None;
1970 }
1971
1972 let elided_range: Range<usize> = {
1973 let byte_range = self.component_ranges[eligible_range.start].1.start
1974 ..self.component_ranges[eligible_range.end - 1].1.end;
1975 let midpoint = self.path_str.len() / 2;
1976 let distance_from_start = byte_range.start.abs_diff(midpoint);
1977 let distance_from_end = byte_range.end.abs_diff(midpoint);
1978 let pick_from_end = distance_from_start > distance_from_end;
1979 let mut len_with_elision = self.path_str.len();
1980 let mut i = eligible_range.start;
1981 while i < eligible_range.end {
1982 let x = if pick_from_end {
1983 eligible_range.end - i + eligible_range.start - 1
1984 } else {
1985 i
1986 };
1987 len_with_elision -= self.component_ranges[x]
1988 .0
1989 .as_os_str()
1990 .as_encoded_bytes()
1991 .len()
1992 + 1;
1993 if len_with_elision <= budget {
1994 break;
1995 }
1996 i += 1;
1997 }
1998 if len_with_elision > budget {
1999 return None;
2000 } else if pick_from_end {
2001 let x = eligible_range.end - i + eligible_range.start - 1;
2002 x..eligible_range.end
2003 } else {
2004 let x = i;
2005 eligible_range.start..x + 1
2006 }
2007 };
2008
2009 let byte_range = self.component_ranges[elided_range.start].1.start
2010 ..self.component_ranges[elided_range.end - 1].1.end;
2011 Some(byte_range)
2012 }
2013}