file_finder.rs

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