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