Sort file finder matches by distance to the active item after match score

Kay Simmons created

Change summary

crates/file_finder/src/file_finder.rs | 26 +++++++++++++++++------
crates/fuzzy/src/matcher.rs           |  1 
crates/fuzzy/src/paths.rs             | 32 +++++++++++++++++++++++++++++
3 files changed, 52 insertions(+), 7 deletions(-)

Detailed changes

crates/file_finder/src/file_finder.rs 🔗

@@ -23,6 +23,7 @@ pub struct FileFinder {
     latest_search_id: usize,
     latest_search_did_cancel: bool,
     latest_search_query: String,
+    relative_to: Option<Arc<Path>>,
     matches: Vec<PathMatch>,
     selected: Option<(usize, Arc<Path>)>,
     cancel_flag: Arc<AtomicBool>,
@@ -90,7 +91,11 @@ impl FileFinder {
     fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
         workspace.toggle_modal(cx, |workspace, cx| {
             let project = workspace.project().clone();
-            let finder = cx.add_view(|cx| Self::new(project, cx));
+            let relative_to = workspace
+                .active_item(cx)
+                .and_then(|item| item.project_path(cx))
+                .map(|project_path| project_path.path.clone());
+            let finder = cx.add_view(|cx| Self::new(project, relative_to, cx));
             cx.subscribe(&finder, Self::on_event).detach();
             finder
         });
@@ -115,7 +120,11 @@ impl FileFinder {
         }
     }
 
-    pub fn new(project: ModelHandle<Project>, cx: &mut ViewContext<Self>) -> Self {
+    pub fn new(
+        project: ModelHandle<Project>,
+        relative_to: Option<Arc<Path>>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
         let handle = cx.weak_handle();
         cx.observe(&project, Self::project_updated).detach();
         Self {
@@ -125,6 +134,7 @@ impl FileFinder {
             latest_search_id: 0,
             latest_search_did_cancel: false,
             latest_search_query: String::new(),
+            relative_to,
             matches: Vec::new(),
             selected: None,
             cancel_flag: Arc::new(AtomicBool::new(false)),
@@ -137,6 +147,7 @@ impl FileFinder {
     }
 
     fn spawn_search(&mut self, query: String, cx: &mut ViewContext<Self>) -> Task<()> {
+        let relative_to = self.relative_to.clone();
         let worktrees = self
             .project
             .read(cx)
@@ -165,6 +176,7 @@ impl FileFinder {
             let matches = fuzzy::match_path_sets(
                 candidate_sets.as_slice(),
                 &query,
+                relative_to,
                 false,
                 100,
                 &cancel_flag,
@@ -377,7 +389,7 @@ mod tests {
             Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
         });
         let (_, finder) =
-            cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), cx));
+            cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), None, cx));
 
         let query = "hi".to_string();
         finder
@@ -453,7 +465,7 @@ mod tests {
             Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
         });
         let (_, finder) =
-            cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), cx));
+            cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), None, cx));
         finder
             .update(cx, |f, cx| f.spawn_search("hi".into(), cx))
             .await;
@@ -479,7 +491,7 @@ mod tests {
             Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
         });
         let (_, finder) =
-            cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), cx));
+            cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), None, cx));
 
         // Even though there is only one worktree, that worktree's filename
         // is included in the matching, because the worktree is a single file.
@@ -533,7 +545,7 @@ mod tests {
             Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
         });
         let (_, finder) =
-            cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), cx));
+            cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), None, cx));
 
         // Run a search that matches two files with the same relative path.
         finder
@@ -573,7 +585,7 @@ mod tests {
             Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
         });
         let (_, finder) =
-            cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), cx));
+            cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), None, cx));
         finder
             .update(cx, |f, cx| f.spawn_search("dir".into(), cx))
             .await;

crates/fuzzy/src/matcher.rs 🔗

@@ -443,6 +443,7 @@ mod tests {
                 positions: Vec::new(),
                 path: candidate.path.clone(),
                 path_prefix: "".into(),
+                distance_to_relative_ancestor: usize::MAX,
             },
         );
 

crates/fuzzy/src/paths.rs 🔗

@@ -25,6 +25,9 @@ pub struct PathMatch {
     pub worktree_id: usize,
     pub path: Arc<Path>,
     pub path_prefix: Arc<str>,
+    /// Number of steps removed from a shared parent with the relative path
+    /// Used to order closer paths first in the search list
+    pub distance_to_relative_ancestor: usize,
 }
 
 pub trait PathMatchCandidateSet<'a>: Send + Sync {
@@ -78,6 +81,11 @@ impl Ord for PathMatch {
             .partial_cmp(&other.score)
             .unwrap_or(Ordering::Equal)
             .then_with(|| self.worktree_id.cmp(&other.worktree_id))
+            .then_with(|| {
+                other
+                    .distance_to_relative_ancestor
+                    .cmp(&self.distance_to_relative_ancestor)
+            })
             .then_with(|| self.path.cmp(&other.path))
     }
 }
@@ -85,6 +93,7 @@ impl Ord for PathMatch {
 pub async fn match_path_sets<'a, Set: PathMatchCandidateSet<'a>>(
     candidate_sets: &'a [Set],
     query: &str,
+    relative_to: Option<Arc<Path>>,
     smart_case: bool,
     max_results: usize,
     cancel_flag: &AtomicBool,
@@ -111,6 +120,7 @@ pub async fn match_path_sets<'a, Set: PathMatchCandidateSet<'a>>(
     background
         .scoped(|scope| {
             for (segment_idx, results) in segment_results.iter_mut().enumerate() {
+                let relative_to = relative_to.clone();
                 scope.spawn(async move {
                     let segment_start = segment_idx * segment_size;
                     let segment_end = segment_start + segment_size;
@@ -149,6 +159,10 @@ pub async fn match_path_sets<'a, Set: PathMatchCandidateSet<'a>>(
                                     positions: Vec::new(),
                                     path: candidate.path.clone(),
                                     path_prefix: candidate_set.prefix(),
+                                    distance_to_relative_ancestor: distance_to_relative_ancestor(
+                                        candidate.path.as_ref(),
+                                        &relative_to,
+                                    ),
                                 },
                             );
                         }
@@ -172,3 +186,21 @@ pub async fn match_path_sets<'a, Set: PathMatchCandidateSet<'a>>(
     }
     results
 }
+
+/// Compute the distance from a given path to some other path
+/// If there is no shared path, returns usize::MAX
+fn distance_to_relative_ancestor(path: &Path, relative_to: &Option<Arc<Path>>) -> usize {
+    let Some(relative_to) = relative_to else {
+        return usize::MAX;
+    };
+
+    for (path_ancestor_count, path_ancestor) in path.ancestors().enumerate() {
+        for (relative_ancestor_count, relative_ancestor) in relative_to.ancestors().enumerate() {
+            if path_ancestor == relative_ancestor {
+                return path_ancestor_count + relative_ancestor_count;
+            }
+        }
+    }
+
+    usize::MAX
+}