file_finder.rs

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