Add recent project picker

Kay Simmons created

Change summary

Cargo.lock                                                   |  19 
Cargo.toml                                                   |   1 
crates/file_finder/src/file_finder.rs                        |   7 
crates/fuzzy/src/fuzzy.rs                                    | 796 -----
crates/fuzzy/src/matcher.rs                                  | 463 +++
crates/fuzzy/src/paths.rs                                    | 174 +
crates/fuzzy/src/strings.rs                                  | 161 +
crates/outline/src/outline.rs                                |   4 
crates/recent_projects/Cargo.toml                            |  22 
crates/recent_projects/src/highlighted_workspace_location.rs | 129 
crates/recent_projects/src/recent_projects.rs                | 187 +
crates/workspace/src/workspace.rs                            |   2 
crates/zed/Cargo.toml                                        |   1 
crates/zed/src/main.rs                                       |   1 
14 files changed, 1,170 insertions(+), 797 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -4805,6 +4805,24 @@ dependencies = [
  "rand_core 0.3.1",
 ]
 
+[[package]]
+name = "recent_projects"
+version = "0.1.0"
+dependencies = [
+ "db",
+ "editor",
+ "fuzzy",
+ "gpui",
+ "language",
+ "ordered-float",
+ "picker",
+ "postage",
+ "settings",
+ "smol",
+ "text",
+ "workspace",
+]
+
 [[package]]
 name = "redox_syscall"
 version = "0.2.16"
@@ -8152,6 +8170,7 @@ dependencies = [
  "project_panel",
  "project_symbols",
  "rand 0.8.5",
+ "recent_projects",
  "regex",
  "rpc",
  "rsa",

Cargo.toml 🔗

@@ -40,6 +40,7 @@ members = [
     "crates/project",
     "crates/project_panel",
     "crates/project_symbols",
+    "crates/recent_projects",
     "crates/rope",
     "crates/rpc",
     "crates/search",

crates/file_finder/src/file_finder.rs 🔗

@@ -62,11 +62,12 @@ impl View for FileFinder {
 
 impl FileFinder {
     fn labels_for_match(&self, path_match: &PathMatch) -> (String, Vec<usize>, String, Vec<usize>) {
-        let path_string = path_match.path.to_string_lossy();
+        let path = &path_match.path;
+        let path_string = path.to_string_lossy();
         let full_path = [path_match.path_prefix.as_ref(), path_string.as_ref()].join("");
         let path_positions = path_match.positions.clone();
 
-        let file_name = path_match.path.file_name().map_or_else(
+        let file_name = path.file_name().map_or_else(
             || path_match.path_prefix.to_string(),
             |file_name| file_name.to_string_lossy().to_string(),
         );
@@ -161,7 +162,7 @@ impl FileFinder {
         self.cancel_flag = Arc::new(AtomicBool::new(false));
         let cancel_flag = self.cancel_flag.clone();
         cx.spawn(|this, mut cx| async move {
-            let matches = fuzzy::match_paths(
+            let matches = fuzzy::match_path_sets(
                 candidate_sets.as_slice(),
                 &query,
                 false,

crates/fuzzy/src/fuzzy.rs 🔗

@@ -1,794 +1,8 @@
 mod char_bag;
-
-use gpui::executor;
-use std::{
-    borrow::Cow,
-    cmp::{self, Ordering},
-    path::Path,
-    sync::atomic::{self, AtomicBool},
-    sync::Arc,
-};
+mod matcher;
+mod paths;
+mod strings;
 
 pub use char_bag::CharBag;
-
-const BASE_DISTANCE_PENALTY: f64 = 0.6;
-const ADDITIONAL_DISTANCE_PENALTY: f64 = 0.05;
-const MIN_DISTANCE_PENALTY: f64 = 0.2;
-
-pub struct Matcher<'a> {
-    query: &'a [char],
-    lowercase_query: &'a [char],
-    query_char_bag: CharBag,
-    smart_case: bool,
-    max_results: usize,
-    min_score: f64,
-    match_positions: Vec<usize>,
-    last_positions: Vec<usize>,
-    score_matrix: Vec<Option<f64>>,
-    best_position_matrix: Vec<usize>,
-}
-
-trait Match: Ord {
-    fn score(&self) -> f64;
-    fn set_positions(&mut self, positions: Vec<usize>);
-}
-
-trait MatchCandidate {
-    fn has_chars(&self, bag: CharBag) -> bool;
-    fn to_string(&self) -> Cow<'_, str>;
-}
-
-#[derive(Clone, Debug)]
-pub struct PathMatchCandidate<'a> {
-    pub path: &'a Arc<Path>,
-    pub char_bag: CharBag,
-}
-
-#[derive(Clone, Debug)]
-pub struct PathMatch {
-    pub score: f64,
-    pub positions: Vec<usize>,
-    pub worktree_id: usize,
-    pub path: Arc<Path>,
-    pub path_prefix: Arc<str>,
-}
-
-#[derive(Clone, Debug)]
-pub struct StringMatchCandidate {
-    pub id: usize,
-    pub string: String,
-    pub char_bag: CharBag,
-}
-
-pub trait PathMatchCandidateSet<'a>: Send + Sync {
-    type Candidates: Iterator<Item = PathMatchCandidate<'a>>;
-    fn id(&self) -> usize;
-    fn len(&self) -> usize;
-    fn is_empty(&self) -> bool {
-        self.len() == 0
-    }
-    fn prefix(&self) -> Arc<str>;
-    fn candidates(&'a self, start: usize) -> Self::Candidates;
-}
-
-impl Match for PathMatch {
-    fn score(&self) -> f64 {
-        self.score
-    }
-
-    fn set_positions(&mut self, positions: Vec<usize>) {
-        self.positions = positions;
-    }
-}
-
-impl Match for StringMatch {
-    fn score(&self) -> f64 {
-        self.score
-    }
-
-    fn set_positions(&mut self, positions: Vec<usize>) {
-        self.positions = positions;
-    }
-}
-
-impl<'a> MatchCandidate for PathMatchCandidate<'a> {
-    fn has_chars(&self, bag: CharBag) -> bool {
-        self.char_bag.is_superset(bag)
-    }
-
-    fn to_string(&self) -> Cow<'a, str> {
-        self.path.to_string_lossy()
-    }
-}
-
-impl StringMatchCandidate {
-    pub fn new(id: usize, string: String) -> Self {
-        Self {
-            id,
-            char_bag: CharBag::from(string.as_str()),
-            string,
-        }
-    }
-}
-
-impl<'a> MatchCandidate for &'a StringMatchCandidate {
-    fn has_chars(&self, bag: CharBag) -> bool {
-        self.char_bag.is_superset(bag)
-    }
-
-    fn to_string(&self) -> Cow<'a, str> {
-        self.string.as_str().into()
-    }
-}
-
-#[derive(Clone, Debug)]
-pub struct StringMatch {
-    pub candidate_id: usize,
-    pub score: f64,
-    pub positions: Vec<usize>,
-    pub string: String,
-}
-
-impl PartialEq for StringMatch {
-    fn eq(&self, other: &Self) -> bool {
-        self.cmp(other).is_eq()
-    }
-}
-
-impl Eq for StringMatch {}
-
-impl PartialOrd for StringMatch {
-    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
-        Some(self.cmp(other))
-    }
-}
-
-impl Ord for StringMatch {
-    fn cmp(&self, other: &Self) -> Ordering {
-        self.score
-            .partial_cmp(&other.score)
-            .unwrap_or(Ordering::Equal)
-            .then_with(|| self.candidate_id.cmp(&other.candidate_id))
-    }
-}
-
-impl PartialEq for PathMatch {
-    fn eq(&self, other: &Self) -> bool {
-        self.cmp(other).is_eq()
-    }
-}
-
-impl Eq for PathMatch {}
-
-impl PartialOrd for PathMatch {
-    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
-        Some(self.cmp(other))
-    }
-}
-
-impl Ord for PathMatch {
-    fn cmp(&self, other: &Self) -> Ordering {
-        self.score
-            .partial_cmp(&other.score)
-            .unwrap_or(Ordering::Equal)
-            .then_with(|| self.worktree_id.cmp(&other.worktree_id))
-            .then_with(|| Arc::as_ptr(&self.path).cmp(&Arc::as_ptr(&other.path)))
-    }
-}
-
-pub async fn match_strings(
-    candidates: &[StringMatchCandidate],
-    query: &str,
-    smart_case: bool,
-    max_results: usize,
-    cancel_flag: &AtomicBool,
-    background: Arc<executor::Background>,
-) -> Vec<StringMatch> {
-    if candidates.is_empty() || max_results == 0 {
-        return Default::default();
-    }
-
-    if query.is_empty() {
-        return candidates
-            .iter()
-            .map(|candidate| StringMatch {
-                candidate_id: candidate.id,
-                score: 0.,
-                positions: Default::default(),
-                string: candidate.string.clone(),
-            })
-            .collect();
-    }
-
-    let lowercase_query = query.to_lowercase().chars().collect::<Vec<_>>();
-    let query = query.chars().collect::<Vec<_>>();
-
-    let lowercase_query = &lowercase_query;
-    let query = &query;
-    let query_char_bag = CharBag::from(&lowercase_query[..]);
-
-    let num_cpus = background.num_cpus().min(candidates.len());
-    let segment_size = (candidates.len() + num_cpus - 1) / num_cpus;
-    let mut segment_results = (0..num_cpus)
-        .map(|_| Vec::with_capacity(max_results.min(candidates.len())))
-        .collect::<Vec<_>>();
-
-    background
-        .scoped(|scope| {
-            for (segment_idx, results) in segment_results.iter_mut().enumerate() {
-                let cancel_flag = &cancel_flag;
-                scope.spawn(async move {
-                    let segment_start = cmp::min(segment_idx * segment_size, candidates.len());
-                    let segment_end = cmp::min(segment_start + segment_size, candidates.len());
-                    let mut matcher = Matcher::new(
-                        query,
-                        lowercase_query,
-                        query_char_bag,
-                        smart_case,
-                        max_results,
-                    );
-                    matcher.match_strings(
-                        &candidates[segment_start..segment_end],
-                        results,
-                        cancel_flag,
-                    );
-                });
-            }
-        })
-        .await;
-
-    let mut results = Vec::new();
-    for segment_result in segment_results {
-        if results.is_empty() {
-            results = segment_result;
-        } else {
-            util::extend_sorted(&mut results, segment_result, max_results, |a, b| b.cmp(a));
-        }
-    }
-    results
-}
-
-pub async fn match_paths<'a, Set: PathMatchCandidateSet<'a>>(
-    candidate_sets: &'a [Set],
-    query: &str,
-    smart_case: bool,
-    max_results: usize,
-    cancel_flag: &AtomicBool,
-    background: Arc<executor::Background>,
-) -> Vec<PathMatch> {
-    let path_count: usize = candidate_sets.iter().map(|s| s.len()).sum();
-    if path_count == 0 {
-        return Vec::new();
-    }
-
-    let lowercase_query = query.to_lowercase().chars().collect::<Vec<_>>();
-    let query = query.chars().collect::<Vec<_>>();
-
-    let lowercase_query = &lowercase_query;
-    let query = &query;
-    let query_char_bag = CharBag::from(&lowercase_query[..]);
-
-    let num_cpus = background.num_cpus().min(path_count);
-    let segment_size = (path_count + num_cpus - 1) / num_cpus;
-    let mut segment_results = (0..num_cpus)
-        .map(|_| Vec::with_capacity(max_results))
-        .collect::<Vec<_>>();
-
-    background
-        .scoped(|scope| {
-            for (segment_idx, results) in segment_results.iter_mut().enumerate() {
-                scope.spawn(async move {
-                    let segment_start = segment_idx * segment_size;
-                    let segment_end = segment_start + segment_size;
-                    let mut matcher = Matcher::new(
-                        query,
-                        lowercase_query,
-                        query_char_bag,
-                        smart_case,
-                        max_results,
-                    );
-
-                    let mut tree_start = 0;
-                    for candidate_set in candidate_sets {
-                        let tree_end = tree_start + candidate_set.len();
-
-                        if tree_start < segment_end && segment_start < tree_end {
-                            let start = cmp::max(tree_start, segment_start) - tree_start;
-                            let end = cmp::min(tree_end, segment_end) - tree_start;
-                            let candidates = candidate_set.candidates(start).take(end - start);
-
-                            matcher.match_paths(
-                                candidate_set.id(),
-                                candidate_set.prefix(),
-                                candidates,
-                                results,
-                                cancel_flag,
-                            );
-                        }
-                        if tree_end >= segment_end {
-                            break;
-                        }
-                        tree_start = tree_end;
-                    }
-                })
-            }
-        })
-        .await;
-
-    let mut results = Vec::new();
-    for segment_result in segment_results {
-        if results.is_empty() {
-            results = segment_result;
-        } else {
-            util::extend_sorted(&mut results, segment_result, max_results, |a, b| b.cmp(a));
-        }
-    }
-    results
-}
-
-impl<'a> Matcher<'a> {
-    pub fn new(
-        query: &'a [char],
-        lowercase_query: &'a [char],
-        query_char_bag: CharBag,
-        smart_case: bool,
-        max_results: usize,
-    ) -> Self {
-        Self {
-            query,
-            lowercase_query,
-            query_char_bag,
-            min_score: 0.0,
-            last_positions: vec![0; query.len()],
-            match_positions: vec![0; query.len()],
-            score_matrix: Vec::new(),
-            best_position_matrix: Vec::new(),
-            smart_case,
-            max_results,
-        }
-    }
-
-    pub fn match_strings(
-        &mut self,
-        candidates: &[StringMatchCandidate],
-        results: &mut Vec<StringMatch>,
-        cancel_flag: &AtomicBool,
-    ) {
-        self.match_internal(
-            &[],
-            &[],
-            candidates.iter(),
-            results,
-            cancel_flag,
-            |candidate, score| StringMatch {
-                candidate_id: candidate.id,
-                score,
-                positions: Vec::new(),
-                string: candidate.string.to_string(),
-            },
-        )
-    }
-
-    pub fn match_paths<'c: 'a>(
-        &mut self,
-        tree_id: usize,
-        path_prefix: Arc<str>,
-        path_entries: impl Iterator<Item = PathMatchCandidate<'c>>,
-        results: &mut Vec<PathMatch>,
-        cancel_flag: &AtomicBool,
-    ) {
-        let prefix = path_prefix.chars().collect::<Vec<_>>();
-        let lowercase_prefix = prefix
-            .iter()
-            .map(|c| c.to_ascii_lowercase())
-            .collect::<Vec<_>>();
-        self.match_internal(
-            &prefix,
-            &lowercase_prefix,
-            path_entries,
-            results,
-            cancel_flag,
-            |candidate, score| PathMatch {
-                score,
-                worktree_id: tree_id,
-                positions: Vec::new(),
-                path: candidate.path.clone(),
-                path_prefix: path_prefix.clone(),
-            },
-        )
-    }
-
-    fn match_internal<C: MatchCandidate, R, F>(
-        &mut self,
-        prefix: &[char],
-        lowercase_prefix: &[char],
-        candidates: impl Iterator<Item = C>,
-        results: &mut Vec<R>,
-        cancel_flag: &AtomicBool,
-        build_match: F,
-    ) where
-        R: Match,
-        F: Fn(&C, f64) -> R,
-    {
-        let mut candidate_chars = Vec::new();
-        let mut lowercase_candidate_chars = Vec::new();
-
-        for candidate in candidates {
-            if !candidate.has_chars(self.query_char_bag) {
-                continue;
-            }
-
-            if cancel_flag.load(atomic::Ordering::Relaxed) {
-                break;
-            }
-
-            candidate_chars.clear();
-            lowercase_candidate_chars.clear();
-            for c in candidate.to_string().chars() {
-                candidate_chars.push(c);
-                lowercase_candidate_chars.push(c.to_ascii_lowercase());
-            }
-
-            if !self.find_last_positions(lowercase_prefix, &lowercase_candidate_chars) {
-                continue;
-            }
-
-            let matrix_len = self.query.len() * (prefix.len() + candidate_chars.len());
-            self.score_matrix.clear();
-            self.score_matrix.resize(matrix_len, None);
-            self.best_position_matrix.clear();
-            self.best_position_matrix.resize(matrix_len, 0);
-
-            let score = self.score_match(
-                &candidate_chars,
-                &lowercase_candidate_chars,
-                prefix,
-                lowercase_prefix,
-            );
-
-            if score > 0.0 {
-                let mut mat = build_match(&candidate, score);
-                if let Err(i) = results.binary_search_by(|m| mat.cmp(m)) {
-                    if results.len() < self.max_results {
-                        mat.set_positions(self.match_positions.clone());
-                        results.insert(i, mat);
-                    } else if i < results.len() {
-                        results.pop();
-                        mat.set_positions(self.match_positions.clone());
-                        results.insert(i, mat);
-                    }
-                    if results.len() == self.max_results {
-                        self.min_score = results.last().unwrap().score();
-                    }
-                }
-            }
-        }
-    }
-
-    fn find_last_positions(
-        &mut self,
-        lowercase_prefix: &[char],
-        lowercase_candidate: &[char],
-    ) -> bool {
-        let mut lowercase_prefix = lowercase_prefix.iter();
-        let mut lowercase_candidate = lowercase_candidate.iter();
-        for (i, char) in self.lowercase_query.iter().enumerate().rev() {
-            if let Some(j) = lowercase_candidate.rposition(|c| c == char) {
-                self.last_positions[i] = j + lowercase_prefix.len();
-            } else if let Some(j) = lowercase_prefix.rposition(|c| c == char) {
-                self.last_positions[i] = j;
-            } else {
-                return false;
-            }
-        }
-        true
-    }
-
-    fn score_match(
-        &mut self,
-        path: &[char],
-        path_cased: &[char],
-        prefix: &[char],
-        lowercase_prefix: &[char],
-    ) -> f64 {
-        let score = self.recursive_score_match(
-            path,
-            path_cased,
-            prefix,
-            lowercase_prefix,
-            0,
-            0,
-            self.query.len() as f64,
-        ) * self.query.len() as f64;
-
-        if score <= 0.0 {
-            return 0.0;
-        }
-
-        let path_len = prefix.len() + path.len();
-        let mut cur_start = 0;
-        let mut byte_ix = 0;
-        let mut char_ix = 0;
-        for i in 0..self.query.len() {
-            let match_char_ix = self.best_position_matrix[i * path_len + cur_start];
-            while char_ix < match_char_ix {
-                let ch = prefix
-                    .get(char_ix)
-                    .or_else(|| path.get(char_ix - prefix.len()))
-                    .unwrap();
-                byte_ix += ch.len_utf8();
-                char_ix += 1;
-            }
-            cur_start = match_char_ix + 1;
-            self.match_positions[i] = byte_ix;
-        }
-
-        score
-    }
-
-    #[allow(clippy::too_many_arguments)]
-    fn recursive_score_match(
-        &mut self,
-        path: &[char],
-        path_cased: &[char],
-        prefix: &[char],
-        lowercase_prefix: &[char],
-        query_idx: usize,
-        path_idx: usize,
-        cur_score: f64,
-    ) -> f64 {
-        if query_idx == self.query.len() {
-            return 1.0;
-        }
-
-        let path_len = prefix.len() + path.len();
-
-        if let Some(memoized) = self.score_matrix[query_idx * path_len + path_idx] {
-            return memoized;
-        }
-
-        let mut score = 0.0;
-        let mut best_position = 0;
-
-        let query_char = self.lowercase_query[query_idx];
-        let limit = self.last_positions[query_idx];
-
-        let mut last_slash = 0;
-        for j in path_idx..=limit {
-            let path_char = if j < prefix.len() {
-                lowercase_prefix[j]
-            } else {
-                path_cased[j - prefix.len()]
-            };
-            let is_path_sep = path_char == '/' || path_char == '\\';
-
-            if query_idx == 0 && is_path_sep {
-                last_slash = j;
-            }
-
-            if query_char == path_char || (is_path_sep && query_char == '_' || query_char == '\\') {
-                let curr = if j < prefix.len() {
-                    prefix[j]
-                } else {
-                    path[j - prefix.len()]
-                };
-
-                let mut char_score = 1.0;
-                if j > path_idx {
-                    let last = if j - 1 < prefix.len() {
-                        prefix[j - 1]
-                    } else {
-                        path[j - 1 - prefix.len()]
-                    };
-
-                    if last == '/' {
-                        char_score = 0.9;
-                    } else if (last == '-' || last == '_' || last == ' ' || last.is_numeric())
-                        || (last.is_lowercase() && curr.is_uppercase())
-                    {
-                        char_score = 0.8;
-                    } else if last == '.' {
-                        char_score = 0.7;
-                    } else if query_idx == 0 {
-                        char_score = BASE_DISTANCE_PENALTY;
-                    } else {
-                        char_score = MIN_DISTANCE_PENALTY.max(
-                            BASE_DISTANCE_PENALTY
-                                - (j - path_idx - 1) as f64 * ADDITIONAL_DISTANCE_PENALTY,
-                        );
-                    }
-                }
-
-                // Apply a severe penalty if the case doesn't match.
-                // This will make the exact matches have higher score than the case-insensitive and the
-                // path insensitive matches.
-                if (self.smart_case || curr == '/') && self.query[query_idx] != curr {
-                    char_score *= 0.001;
-                }
-
-                let mut multiplier = char_score;
-
-                // Scale the score based on how deep within the path we found the match.
-                if query_idx == 0 {
-                    multiplier /= ((prefix.len() + path.len()) - last_slash) as f64;
-                }
-
-                let mut next_score = 1.0;
-                if self.min_score > 0.0 {
-                    next_score = cur_score * multiplier;
-                    // Scores only decrease. If we can't pass the previous best, bail
-                    if next_score < self.min_score {
-                        // Ensure that score is non-zero so we use it in the memo table.
-                        if score == 0.0 {
-                            score = 1e-18;
-                        }
-                        continue;
-                    }
-                }
-
-                let new_score = self.recursive_score_match(
-                    path,
-                    path_cased,
-                    prefix,
-                    lowercase_prefix,
-                    query_idx + 1,
-                    j + 1,
-                    next_score,
-                ) * multiplier;
-
-                if new_score > score {
-                    score = new_score;
-                    best_position = j;
-                    // Optimization: can't score better than 1.
-                    if new_score == 1.0 {
-                        break;
-                    }
-                }
-            }
-        }
-
-        if best_position != 0 {
-            self.best_position_matrix[query_idx * path_len + path_idx] = best_position;
-        }
-
-        self.score_matrix[query_idx * path_len + path_idx] = Some(score);
-        score
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use std::path::PathBuf;
-
-    #[test]
-    fn test_get_last_positions() {
-        let mut query: &[char] = &['d', 'c'];
-        let mut matcher = Matcher::new(query, query, query.into(), false, 10);
-        let result = matcher.find_last_positions(&['a', 'b', 'c'], &['b', 'd', 'e', 'f']);
-        assert!(!result);
-
-        query = &['c', 'd'];
-        let mut matcher = Matcher::new(query, query, query.into(), false, 10);
-        let result = matcher.find_last_positions(&['a', 'b', 'c'], &['b', 'd', 'e', 'f']);
-        assert!(result);
-        assert_eq!(matcher.last_positions, vec![2, 4]);
-
-        query = &['z', '/', 'z', 'f'];
-        let mut matcher = Matcher::new(query, query, query.into(), false, 10);
-        let result = matcher.find_last_positions(&['z', 'e', 'd', '/'], &['z', 'e', 'd', '/', 'f']);
-        assert!(result);
-        assert_eq!(matcher.last_positions, vec![0, 3, 4, 8]);
-    }
-
-    #[test]
-    fn test_match_path_entries() {
-        let paths = vec![
-            "",
-            "a",
-            "ab",
-            "abC",
-            "abcd",
-            "alphabravocharlie",
-            "AlphaBravoCharlie",
-            "thisisatestdir",
-            "/////ThisIsATestDir",
-            "/this/is/a/test/dir",
-            "/test/tiatd",
-        ];
-
-        assert_eq!(
-            match_query("abc", false, &paths),
-            vec![
-                ("abC", vec![0, 1, 2]),
-                ("abcd", vec![0, 1, 2]),
-                ("AlphaBravoCharlie", vec![0, 5, 10]),
-                ("alphabravocharlie", vec![4, 5, 10]),
-            ]
-        );
-        assert_eq!(
-            match_query("t/i/a/t/d", false, &paths),
-            vec![("/this/is/a/test/dir", vec![1, 5, 6, 8, 9, 10, 11, 15, 16]),]
-        );
-
-        assert_eq!(
-            match_query("tiatd", false, &paths),
-            vec![
-                ("/test/tiatd", vec![6, 7, 8, 9, 10]),
-                ("/this/is/a/test/dir", vec![1, 6, 9, 11, 16]),
-                ("/////ThisIsATestDir", vec![5, 9, 11, 12, 16]),
-                ("thisisatestdir", vec![0, 2, 6, 7, 11]),
-            ]
-        );
-    }
-
-    #[test]
-    fn test_match_multibyte_path_entries() {
-        let paths = vec!["aαbβ/cγdδ", "αβγδ/bcde", "c1️⃣2️⃣3️⃣/d4️⃣5️⃣6️⃣/e7️⃣8️⃣9️⃣/f", "/d/🆒/h"];
-        assert_eq!("1️⃣".len(), 7);
-        assert_eq!(
-            match_query("bcd", false, &paths),
-            vec![
-                ("αβγδ/bcde", vec![9, 10, 11]),
-                ("aαbβ/cγdδ", vec![3, 7, 10]),
-            ]
-        );
-        assert_eq!(
-            match_query("cde", false, &paths),
-            vec![
-                ("αβγδ/bcde", vec![10, 11, 12]),
-                ("c1️⃣2️⃣3️⃣/d4️⃣5️⃣6️⃣/e7️⃣8️⃣9️⃣/f", vec![0, 23, 46]),
-            ]
-        );
-    }
-
-    fn match_query<'a>(
-        query: &str,
-        smart_case: bool,
-        paths: &[&'a str],
-    ) -> Vec<(&'a str, Vec<usize>)> {
-        let lowercase_query = query.to_lowercase().chars().collect::<Vec<_>>();
-        let query = query.chars().collect::<Vec<_>>();
-        let query_chars = CharBag::from(&lowercase_query[..]);
-
-        let path_arcs = paths
-            .iter()
-            .map(|path| Arc::from(PathBuf::from(path)))
-            .collect::<Vec<_>>();
-        let mut path_entries = Vec::new();
-        for (i, path) in paths.iter().enumerate() {
-            let lowercase_path = path.to_lowercase().chars().collect::<Vec<_>>();
-            let char_bag = CharBag::from(lowercase_path.as_slice());
-            path_entries.push(PathMatchCandidate {
-                char_bag,
-                path: path_arcs.get(i).unwrap(),
-            });
-        }
-
-        let mut matcher = Matcher::new(&query, &lowercase_query, query_chars, smart_case, 100);
-
-        let cancel_flag = AtomicBool::new(false);
-        let mut results = Vec::new();
-        matcher.match_paths(
-            0,
-            "".into(),
-            path_entries.into_iter(),
-            &mut results,
-            &cancel_flag,
-        );
-
-        results
-            .into_iter()
-            .map(|result| {
-                (
-                    paths
-                        .iter()
-                        .copied()
-                        .find(|p| result.path.as_ref() == Path::new(p))
-                        .unwrap(),
-                    result.positions,
-                )
-            })
-            .collect()
-    }
-}
+pub use paths::{match_path_sets, PathMatch, PathMatchCandidate, PathMatchCandidateSet};
+pub use strings::{match_strings, StringMatch, StringMatchCandidate};

crates/fuzzy/src/matcher.rs 🔗

@@ -0,0 +1,463 @@
+use std::{
+    borrow::Cow,
+    sync::atomic::{self, AtomicBool},
+};
+
+use crate::CharBag;
+
+const BASE_DISTANCE_PENALTY: f64 = 0.6;
+const ADDITIONAL_DISTANCE_PENALTY: f64 = 0.05;
+const MIN_DISTANCE_PENALTY: f64 = 0.2;
+
+pub struct Matcher<'a> {
+    query: &'a [char],
+    lowercase_query: &'a [char],
+    query_char_bag: CharBag,
+    smart_case: bool,
+    max_results: usize,
+    min_score: f64,
+    match_positions: Vec<usize>,
+    last_positions: Vec<usize>,
+    score_matrix: Vec<Option<f64>>,
+    best_position_matrix: Vec<usize>,
+}
+
+pub trait Match: Ord {
+    fn score(&self) -> f64;
+    fn set_positions(&mut self, positions: Vec<usize>);
+}
+
+pub trait MatchCandidate {
+    fn has_chars(&self, bag: CharBag) -> bool;
+    fn to_string(&self) -> Cow<'_, str>;
+}
+
+impl<'a> Matcher<'a> {
+    pub fn new(
+        query: &'a [char],
+        lowercase_query: &'a [char],
+        query_char_bag: CharBag,
+        smart_case: bool,
+        max_results: usize,
+    ) -> Self {
+        Self {
+            query,
+            lowercase_query,
+            query_char_bag,
+            min_score: 0.0,
+            last_positions: vec![0; query.len()],
+            match_positions: vec![0; query.len()],
+            score_matrix: Vec::new(),
+            best_position_matrix: Vec::new(),
+            smart_case,
+            max_results,
+        }
+    }
+
+    pub fn match_candidates<C: MatchCandidate, R, F>(
+        &mut self,
+        prefix: &[char],
+        lowercase_prefix: &[char],
+        candidates: impl Iterator<Item = C>,
+        results: &mut Vec<R>,
+        cancel_flag: &AtomicBool,
+        build_match: F,
+    ) where
+        R: Match,
+        F: Fn(&C, f64) -> R,
+    {
+        let mut candidate_chars = Vec::new();
+        let mut lowercase_candidate_chars = Vec::new();
+
+        for candidate in candidates {
+            if !candidate.has_chars(self.query_char_bag) {
+                continue;
+            }
+
+            if cancel_flag.load(atomic::Ordering::Relaxed) {
+                break;
+            }
+
+            candidate_chars.clear();
+            lowercase_candidate_chars.clear();
+            for c in candidate.to_string().chars() {
+                candidate_chars.push(c);
+                lowercase_candidate_chars.push(c.to_ascii_lowercase());
+            }
+
+            if !self.find_last_positions(lowercase_prefix, &lowercase_candidate_chars) {
+                continue;
+            }
+
+            let matrix_len = self.query.len() * (prefix.len() + candidate_chars.len());
+            self.score_matrix.clear();
+            self.score_matrix.resize(matrix_len, None);
+            self.best_position_matrix.clear();
+            self.best_position_matrix.resize(matrix_len, 0);
+
+            let score = self.score_match(
+                &candidate_chars,
+                &lowercase_candidate_chars,
+                prefix,
+                lowercase_prefix,
+            );
+
+            if score > 0.0 {
+                let mut mat = build_match(&candidate, score);
+                if let Err(i) = results.binary_search_by(|m| mat.cmp(m)) {
+                    if results.len() < self.max_results {
+                        mat.set_positions(self.match_positions.clone());
+                        results.insert(i, mat);
+                    } else if i < results.len() {
+                        results.pop();
+                        mat.set_positions(self.match_positions.clone());
+                        results.insert(i, mat);
+                    }
+                    if results.len() == self.max_results {
+                        self.min_score = results.last().unwrap().score();
+                    }
+                }
+            }
+        }
+    }
+
+    fn find_last_positions(
+        &mut self,
+        lowercase_prefix: &[char],
+        lowercase_candidate: &[char],
+    ) -> bool {
+        let mut lowercase_prefix = lowercase_prefix.iter();
+        let mut lowercase_candidate = lowercase_candidate.iter();
+        for (i, char) in self.lowercase_query.iter().enumerate().rev() {
+            if let Some(j) = lowercase_candidate.rposition(|c| c == char) {
+                self.last_positions[i] = j + lowercase_prefix.len();
+            } else if let Some(j) = lowercase_prefix.rposition(|c| c == char) {
+                self.last_positions[i] = j;
+            } else {
+                return false;
+            }
+        }
+        true
+    }
+
+    fn score_match(
+        &mut self,
+        path: &[char],
+        path_cased: &[char],
+        prefix: &[char],
+        lowercase_prefix: &[char],
+    ) -> f64 {
+        let score = self.recursive_score_match(
+            path,
+            path_cased,
+            prefix,
+            lowercase_prefix,
+            0,
+            0,
+            self.query.len() as f64,
+        ) * self.query.len() as f64;
+
+        if score <= 0.0 {
+            return 0.0;
+        }
+
+        let path_len = prefix.len() + path.len();
+        let mut cur_start = 0;
+        let mut byte_ix = 0;
+        let mut char_ix = 0;
+        for i in 0..self.query.len() {
+            let match_char_ix = self.best_position_matrix[i * path_len + cur_start];
+            while char_ix < match_char_ix {
+                let ch = prefix
+                    .get(char_ix)
+                    .or_else(|| path.get(char_ix - prefix.len()))
+                    .unwrap();
+                byte_ix += ch.len_utf8();
+                char_ix += 1;
+            }
+            cur_start = match_char_ix + 1;
+            self.match_positions[i] = byte_ix;
+        }
+
+        score
+    }
+
+    #[allow(clippy::too_many_arguments)]
+    fn recursive_score_match(
+        &mut self,
+        path: &[char],
+        path_cased: &[char],
+        prefix: &[char],
+        lowercase_prefix: &[char],
+        query_idx: usize,
+        path_idx: usize,
+        cur_score: f64,
+    ) -> f64 {
+        if query_idx == self.query.len() {
+            return 1.0;
+        }
+
+        let path_len = prefix.len() + path.len();
+
+        if let Some(memoized) = self.score_matrix[query_idx * path_len + path_idx] {
+            return memoized;
+        }
+
+        let mut score = 0.0;
+        let mut best_position = 0;
+
+        let query_char = self.lowercase_query[query_idx];
+        let limit = self.last_positions[query_idx];
+
+        let mut last_slash = 0;
+        for j in path_idx..=limit {
+            let path_char = if j < prefix.len() {
+                lowercase_prefix[j]
+            } else {
+                path_cased[j - prefix.len()]
+            };
+            let is_path_sep = path_char == '/' || path_char == '\\';
+
+            if query_idx == 0 && is_path_sep {
+                last_slash = j;
+            }
+
+            if query_char == path_char || (is_path_sep && query_char == '_' || query_char == '\\') {
+                let curr = if j < prefix.len() {
+                    prefix[j]
+                } else {
+                    path[j - prefix.len()]
+                };
+
+                let mut char_score = 1.0;
+                if j > path_idx {
+                    let last = if j - 1 < prefix.len() {
+                        prefix[j - 1]
+                    } else {
+                        path[j - 1 - prefix.len()]
+                    };
+
+                    if last == '/' {
+                        char_score = 0.9;
+                    } else if (last == '-' || last == '_' || last == ' ' || last.is_numeric())
+                        || (last.is_lowercase() && curr.is_uppercase())
+                    {
+                        char_score = 0.8;
+                    } else if last == '.' {
+                        char_score = 0.7;
+                    } else if query_idx == 0 {
+                        char_score = BASE_DISTANCE_PENALTY;
+                    } else {
+                        char_score = MIN_DISTANCE_PENALTY.max(
+                            BASE_DISTANCE_PENALTY
+                                - (j - path_idx - 1) as f64 * ADDITIONAL_DISTANCE_PENALTY,
+                        );
+                    }
+                }
+
+                // Apply a severe penalty if the case doesn't match.
+                // This will make the exact matches have higher score than the case-insensitive and the
+                // path insensitive matches.
+                if (self.smart_case || curr == '/') && self.query[query_idx] != curr {
+                    char_score *= 0.001;
+                }
+
+                let mut multiplier = char_score;
+
+                // Scale the score based on how deep within the path we found the match.
+                if query_idx == 0 {
+                    multiplier /= ((prefix.len() + path.len()) - last_slash) as f64;
+                }
+
+                let mut next_score = 1.0;
+                if self.min_score > 0.0 {
+                    next_score = cur_score * multiplier;
+                    // Scores only decrease. If we can't pass the previous best, bail
+                    if next_score < self.min_score {
+                        // Ensure that score is non-zero so we use it in the memo table.
+                        if score == 0.0 {
+                            score = 1e-18;
+                        }
+                        continue;
+                    }
+                }
+
+                let new_score = self.recursive_score_match(
+                    path,
+                    path_cased,
+                    prefix,
+                    lowercase_prefix,
+                    query_idx + 1,
+                    j + 1,
+                    next_score,
+                ) * multiplier;
+
+                if new_score > score {
+                    score = new_score;
+                    best_position = j;
+                    // Optimization: can't score better than 1.
+                    if new_score == 1.0 {
+                        break;
+                    }
+                }
+            }
+        }
+
+        if best_position != 0 {
+            self.best_position_matrix[query_idx * path_len + path_idx] = best_position;
+        }
+
+        self.score_matrix[query_idx * path_len + path_idx] = Some(score);
+        score
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::{PathMatch, PathMatchCandidate};
+
+    use super::*;
+    use std::{
+        path::{Path, PathBuf},
+        sync::Arc,
+    };
+
+    #[test]
+    fn test_get_last_positions() {
+        let mut query: &[char] = &['d', 'c'];
+        let mut matcher = Matcher::new(query, query, query.into(), false, 10);
+        let result = matcher.find_last_positions(&['a', 'b', 'c'], &['b', 'd', 'e', 'f']);
+        assert!(!result);
+
+        query = &['c', 'd'];
+        let mut matcher = Matcher::new(query, query, query.into(), false, 10);
+        let result = matcher.find_last_positions(&['a', 'b', 'c'], &['b', 'd', 'e', 'f']);
+        assert!(result);
+        assert_eq!(matcher.last_positions, vec![2, 4]);
+
+        query = &['z', '/', 'z', 'f'];
+        let mut matcher = Matcher::new(query, query, query.into(), false, 10);
+        let result = matcher.find_last_positions(&['z', 'e', 'd', '/'], &['z', 'e', 'd', '/', 'f']);
+        assert!(result);
+        assert_eq!(matcher.last_positions, vec![0, 3, 4, 8]);
+    }
+
+    #[test]
+    fn test_match_path_entries() {
+        let paths = vec![
+            "",
+            "a",
+            "ab",
+            "abC",
+            "abcd",
+            "alphabravocharlie",
+            "AlphaBravoCharlie",
+            "thisisatestdir",
+            "/////ThisIsATestDir",
+            "/this/is/a/test/dir",
+            "/test/tiatd",
+        ];
+
+        assert_eq!(
+            match_single_path_query("abc", false, &paths),
+            vec![
+                ("abC", vec![0, 1, 2]),
+                ("abcd", vec![0, 1, 2]),
+                ("AlphaBravoCharlie", vec![0, 5, 10]),
+                ("alphabravocharlie", vec![4, 5, 10]),
+            ]
+        );
+        assert_eq!(
+            match_single_path_query("t/i/a/t/d", false, &paths),
+            vec![("/this/is/a/test/dir", vec![1, 5, 6, 8, 9, 10, 11, 15, 16]),]
+        );
+
+        assert_eq!(
+            match_single_path_query("tiatd", false, &paths),
+            vec![
+                ("/test/tiatd", vec![6, 7, 8, 9, 10]),
+                ("/this/is/a/test/dir", vec![1, 6, 9, 11, 16]),
+                ("/////ThisIsATestDir", vec![5, 9, 11, 12, 16]),
+                ("thisisatestdir", vec![0, 2, 6, 7, 11]),
+            ]
+        );
+    }
+
+    #[test]
+    fn test_match_multibyte_path_entries() {
+        let paths = vec!["aαbβ/cγdδ", "αβγδ/bcde", "c1️⃣2️⃣3️⃣/d4️⃣5️⃣6️⃣/e7️⃣8️⃣9️⃣/f", "/d/🆒/h"];
+        assert_eq!("1️⃣".len(), 7);
+        assert_eq!(
+            match_single_path_query("bcd", false, &paths),
+            vec![
+                ("αβγδ/bcde", vec![9, 10, 11]),
+                ("aαbβ/cγdδ", vec![3, 7, 10]),
+            ]
+        );
+        assert_eq!(
+            match_single_path_query("cde", false, &paths),
+            vec![
+                ("αβγδ/bcde", vec![10, 11, 12]),
+                ("c1️⃣2️⃣3️⃣/d4️⃣5️⃣6️⃣/e7️⃣8️⃣9️⃣/f", vec![0, 23, 46]),
+            ]
+        );
+    }
+
+    fn match_single_path_query<'a>(
+        query: &str,
+        smart_case: bool,
+        paths: &[&'a str],
+    ) -> Vec<(&'a str, Vec<usize>)> {
+        let lowercase_query = query.to_lowercase().chars().collect::<Vec<_>>();
+        let query = query.chars().collect::<Vec<_>>();
+        let query_chars = CharBag::from(&lowercase_query[..]);
+
+        let path_arcs: Vec<Arc<Path>> = paths
+            .iter()
+            .map(|path| Arc::from(PathBuf::from(path)))
+            .collect::<Vec<_>>();
+        let mut path_entries = Vec::new();
+        for (i, path) in paths.iter().enumerate() {
+            let lowercase_path = path.to_lowercase().chars().collect::<Vec<_>>();
+            let char_bag = CharBag::from(lowercase_path.as_slice());
+            path_entries.push(PathMatchCandidate {
+                char_bag,
+                path: &path_arcs[i],
+            });
+        }
+
+        let mut matcher = Matcher::new(&query, &lowercase_query, query_chars, smart_case, 100);
+
+        let cancel_flag = AtomicBool::new(false);
+        let mut results = Vec::new();
+
+        matcher.match_candidates(
+            &[],
+            &[],
+            path_entries.into_iter(),
+            &mut results,
+            &cancel_flag,
+            |candidate, score| PathMatch {
+                score,
+                worktree_id: 0,
+                positions: Vec::new(),
+                path: candidate.path.clone(),
+                path_prefix: "".into(),
+            },
+        );
+
+        results
+            .into_iter()
+            .map(|result| {
+                (
+                    paths
+                        .iter()
+                        .copied()
+                        .find(|p| result.path.as_ref() == Path::new(p))
+                        .unwrap(),
+                    result.positions,
+                )
+            })
+            .collect()
+    }
+}

crates/fuzzy/src/paths.rs 🔗

@@ -0,0 +1,174 @@
+use std::{
+    borrow::Cow,
+    cmp::{self, Ordering},
+    path::Path,
+    sync::{atomic::AtomicBool, Arc},
+};
+
+use gpui::executor;
+
+use crate::{
+    matcher::{Match, MatchCandidate, Matcher},
+    CharBag,
+};
+
+#[derive(Clone, Debug)]
+pub struct PathMatchCandidate<'a> {
+    pub path: &'a Arc<Path>,
+    pub char_bag: CharBag,
+}
+
+#[derive(Clone, Debug)]
+pub struct PathMatch {
+    pub score: f64,
+    pub positions: Vec<usize>,
+    pub worktree_id: usize,
+    pub path: Arc<Path>,
+    pub path_prefix: Arc<str>,
+}
+
+pub trait PathMatchCandidateSet<'a>: Send + Sync {
+    type Candidates: Iterator<Item = PathMatchCandidate<'a>>;
+    fn id(&self) -> usize;
+    fn len(&self) -> usize;
+    fn is_empty(&self) -> bool {
+        self.len() == 0
+    }
+    fn prefix(&self) -> Arc<str>;
+    fn candidates(&'a self, start: usize) -> Self::Candidates;
+}
+
+impl Match for PathMatch {
+    fn score(&self) -> f64 {
+        self.score
+    }
+
+    fn set_positions(&mut self, positions: Vec<usize>) {
+        self.positions = positions;
+    }
+}
+
+impl<'a> MatchCandidate for PathMatchCandidate<'a> {
+    fn has_chars(&self, bag: CharBag) -> bool {
+        self.char_bag.is_superset(bag)
+    }
+
+    fn to_string(&self) -> Cow<'a, str> {
+        self.path.to_string_lossy()
+    }
+}
+
+impl PartialEq for PathMatch {
+    fn eq(&self, other: &Self) -> bool {
+        self.cmp(other).is_eq()
+    }
+}
+
+impl Eq for PathMatch {}
+
+impl PartialOrd for PathMatch {
+    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+        Some(self.cmp(other))
+    }
+}
+
+impl Ord for PathMatch {
+    fn cmp(&self, other: &Self) -> Ordering {
+        self.score
+            .partial_cmp(&other.score)
+            .unwrap_or(Ordering::Equal)
+            .then_with(|| self.worktree_id.cmp(&other.worktree_id))
+            .then_with(|| self.path.cmp(&other.path))
+    }
+}
+
+pub async fn match_path_sets<'a, Set: PathMatchCandidateSet<'a>>(
+    candidate_sets: &'a [Set],
+    query: &str,
+    smart_case: bool,
+    max_results: usize,
+    cancel_flag: &AtomicBool,
+    background: Arc<executor::Background>,
+) -> Vec<PathMatch> {
+    let path_count: usize = candidate_sets.iter().map(|s| s.len()).sum();
+    if path_count == 0 {
+        return Vec::new();
+    }
+
+    let lowercase_query = query.to_lowercase().chars().collect::<Vec<_>>();
+    let query = query.chars().collect::<Vec<_>>();
+
+    let lowercase_query = &lowercase_query;
+    let query = &query;
+    let query_char_bag = CharBag::from(&lowercase_query[..]);
+
+    let num_cpus = background.num_cpus().min(path_count);
+    let segment_size = (path_count + num_cpus - 1) / num_cpus;
+    let mut segment_results = (0..num_cpus)
+        .map(|_| Vec::with_capacity(max_results))
+        .collect::<Vec<_>>();
+
+    background
+        .scoped(|scope| {
+            for (segment_idx, results) in segment_results.iter_mut().enumerate() {
+                scope.spawn(async move {
+                    let segment_start = segment_idx * segment_size;
+                    let segment_end = segment_start + segment_size;
+                    let mut matcher = Matcher::new(
+                        query,
+                        lowercase_query,
+                        query_char_bag,
+                        smart_case,
+                        max_results,
+                    );
+
+                    let mut tree_start = 0;
+                    for candidate_set in candidate_sets {
+                        let tree_end = tree_start + candidate_set.len();
+
+                        if tree_start < segment_end && segment_start < tree_end {
+                            let start = cmp::max(tree_start, segment_start) - tree_start;
+                            let end = cmp::min(tree_end, segment_end) - tree_start;
+                            let candidates = candidate_set.candidates(start).take(end - start);
+
+                            let worktree_id = candidate_set.id();
+                            let prefix = candidate_set.prefix().chars().collect::<Vec<_>>();
+                            let lowercase_prefix = prefix
+                                .iter()
+                                .map(|c| c.to_ascii_lowercase())
+                                .collect::<Vec<_>>();
+                            matcher.match_candidates(
+                                &prefix,
+                                &lowercase_prefix,
+                                candidates,
+                                results,
+                                cancel_flag,
+                                |candidate, score| PathMatch {
+                                    score,
+                                    worktree_id,
+                                    positions: Vec::new(),
+                                    path: candidate.path.clone(),
+                                    path_prefix: candidate_set.prefix(),
+                                },
+                            );
+                        }
+                        if tree_end >= segment_end {
+                            break;
+                        }
+                        tree_start = tree_end;
+                    }
+                })
+            }
+        })
+        .await;
+
+    let mut results = Vec::new();
+    for segment_result in segment_results {
+        if results.is_empty() {
+            results = segment_result;
+        } else {
+            util::extend_sorted(&mut results, segment_result, max_results, |a, b| b.cmp(a));
+        }
+    }
+    results
+}

crates/fuzzy/src/strings.rs 🔗

@@ -0,0 +1,161 @@
+use std::{
+    borrow::Cow,
+    cmp::{self, Ordering},
+    sync::{atomic::AtomicBool, Arc},
+};
+
+use gpui::executor;
+
+use crate::{
+    matcher::{Match, MatchCandidate, Matcher},
+    CharBag,
+};
+
+#[derive(Clone, Debug)]
+pub struct StringMatchCandidate {
+    pub id: usize,
+    pub string: String,
+    pub char_bag: CharBag,
+}
+
+impl Match for StringMatch {
+    fn score(&self) -> f64 {
+        self.score
+    }
+
+    fn set_positions(&mut self, positions: Vec<usize>) {
+        self.positions = positions;
+    }
+}
+
+impl StringMatchCandidate {
+    pub fn new(id: usize, string: String) -> Self {
+        Self {
+            id,
+            char_bag: CharBag::from(string.as_str()),
+            string,
+        }
+    }
+}
+
+impl<'a> MatchCandidate for &'a StringMatchCandidate {
+    fn has_chars(&self, bag: CharBag) -> bool {
+        self.char_bag.is_superset(bag)
+    }
+
+    fn to_string(&self) -> Cow<'a, str> {
+        self.string.as_str().into()
+    }
+}
+
+#[derive(Clone, Debug)]
+pub struct StringMatch {
+    pub candidate_id: usize,
+    pub score: f64,
+    pub positions: Vec<usize>,
+    pub string: String,
+}
+
+impl PartialEq for StringMatch {
+    fn eq(&self, other: &Self) -> bool {
+        self.cmp(other).is_eq()
+    }
+}
+
+impl Eq for StringMatch {}
+
+impl PartialOrd for StringMatch {
+    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+        Some(self.cmp(other))
+    }
+}
+
+impl Ord for StringMatch {
+    fn cmp(&self, other: &Self) -> Ordering {
+        self.score
+            .partial_cmp(&other.score)
+            .unwrap_or(Ordering::Equal)
+            .then_with(|| self.candidate_id.cmp(&other.candidate_id))
+    }
+}
+
+pub async fn match_strings(
+    candidates: &[StringMatchCandidate],
+    query: &str,
+    smart_case: bool,
+    max_results: usize,
+    cancel_flag: &AtomicBool,
+    background: Arc<executor::Background>,
+) -> Vec<StringMatch> {
+    if candidates.is_empty() || max_results == 0 {
+        return Default::default();
+    }
+
+    if query.is_empty() {
+        return candidates
+            .iter()
+            .map(|candidate| StringMatch {
+                candidate_id: candidate.id,
+                score: 0.,
+                positions: Default::default(),
+                string: candidate.string.clone(),
+            })
+            .collect();
+    }
+
+    let lowercase_query = query.to_lowercase().chars().collect::<Vec<_>>();
+    let query = query.chars().collect::<Vec<_>>();
+
+    let lowercase_query = &lowercase_query;
+    let query = &query;
+    let query_char_bag = CharBag::from(&lowercase_query[..]);
+
+    let num_cpus = background.num_cpus().min(candidates.len());
+    let segment_size = (candidates.len() + num_cpus - 1) / num_cpus;
+    let mut segment_results = (0..num_cpus)
+        .map(|_| Vec::with_capacity(max_results.min(candidates.len())))
+        .collect::<Vec<_>>();
+
+    background
+        .scoped(|scope| {
+            for (segment_idx, results) in segment_results.iter_mut().enumerate() {
+                let cancel_flag = &cancel_flag;
+                scope.spawn(async move {
+                    let segment_start = cmp::min(segment_idx * segment_size, candidates.len());
+                    let segment_end = cmp::min(segment_start + segment_size, candidates.len());
+                    let mut matcher = Matcher::new(
+                        query,
+                        lowercase_query,
+                        query_char_bag,
+                        smart_case,
+                        max_results,
+                    );
+
+                    matcher.match_candidates(
+                        &[],
+                        &[],
+                        candidates[segment_start..segment_end].iter(),
+                        results,
+                        cancel_flag,
+                        |candidate, score| StringMatch {
+                            candidate_id: candidate.id,
+                            score,
+                            positions: Vec::new(),
+                            string: candidate.string.to_string(),
+                        },
+                    );
+                });
+            }
+        })
+        .await;
+
+    let mut results = Vec::new();
+    for segment_result in segment_results {
+        if results.is_empty() {
+            results = segment_result;
+        } else {
+            util::extend_sorted(&mut results, segment_result, max_results, |a, b| b.cmp(a));
+        }
+    }
+    results
+}

crates/outline/src/outline.rs 🔗

@@ -84,13 +84,13 @@ impl OutlineView {
             .active_item(cx)
             .and_then(|item| item.downcast::<Editor>())
         {
-            let buffer = editor
+            let outline = editor
                 .read(cx)
                 .buffer()
                 .read(cx)
                 .snapshot(cx)
                 .outline(Some(cx.global::<Settings>().theme.editor.syntax.as_ref()));
-            if let Some(outline) = buffer {
+            if let Some(outline) = outline {
                 workspace.toggle_modal(cx, |_, cx| {
                     let view = cx.add_view(|cx| OutlineView::new(outline, editor, cx));
                     cx.subscribe(&view, Self::on_event).detach();

crates/recent_projects/Cargo.toml 🔗

@@ -0,0 +1,22 @@
+[package]
+name = "recent_projects"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+path = "src/recent_projects.rs"
+doctest = false
+
+[dependencies]
+db = { path = "../db" }
+editor = { path = "../editor" }
+fuzzy = { path = "../fuzzy" }
+gpui = { path = "../gpui" }
+language = { path = "../language" }
+picker = { path = "../picker" }
+settings = { path = "../settings" }
+text = { path = "../text" }
+workspace = { path = "../workspace" }
+ordered-float = "2.1.1"
+postage = { version = "0.4", features = ["futures-traits"] }
+smol = "1.2"

crates/recent_projects/src/highlighted_workspace_location.rs 🔗

@@ -0,0 +1,129 @@
+use std::path::Path;
+
+use fuzzy::StringMatch;
+use gpui::{
+    elements::{Label, LabelStyle},
+    Element, ElementBox,
+};
+use workspace::WorkspaceLocation;
+
+pub struct HighlightedText {
+    pub text: String,
+    pub highlight_positions: Vec<usize>,
+    char_count: usize,
+}
+
+impl HighlightedText {
+    fn join(components: impl Iterator<Item = Self>, separator: &str) -> Self {
+        let mut char_count = 0;
+        let separator_char_count = separator.chars().count();
+        let mut text = String::new();
+        let mut highlight_positions = Vec::new();
+        for component in components {
+            if char_count != 0 {
+                text.push_str(separator);
+                char_count += separator_char_count;
+            }
+
+            highlight_positions.extend(
+                component
+                    .highlight_positions
+                    .iter()
+                    .map(|position| position + char_count),
+            );
+            text.push_str(&component.text);
+            char_count += component.text.chars().count();
+        }
+
+        Self {
+            text,
+            highlight_positions,
+            char_count,
+        }
+    }
+
+    pub fn render(self, style: impl Into<LabelStyle>) -> ElementBox {
+        Label::new(self.text, style)
+            .with_highlights(self.highlight_positions)
+            .boxed()
+    }
+}
+
+pub struct HighlightedWorkspaceLocation {
+    pub names: HighlightedText,
+    pub paths: Vec<HighlightedText>,
+}
+
+impl HighlightedWorkspaceLocation {
+    pub fn new(string_match: &StringMatch, location: &WorkspaceLocation) -> Self {
+        let mut path_start_offset = 0;
+        let (names, paths): (Vec<_>, Vec<_>) = location
+            .paths()
+            .iter()
+            .map(|path| {
+                let highlighted_text = Self::highlights_for_path(
+                    path.as_ref(),
+                    &string_match.positions,
+                    path_start_offset,
+                );
+
+                path_start_offset += highlighted_text.1.char_count;
+
+                highlighted_text
+            })
+            .unzip();
+
+        Self {
+            names: HighlightedText::join(names.into_iter().filter_map(|name| name), ", "),
+            paths,
+        }
+    }
+
+    // Compute the highlighted text for the name and path
+    fn highlights_for_path(
+        path: &Path,
+        match_positions: &Vec<usize>,
+        path_start_offset: usize,
+    ) -> (Option<HighlightedText>, HighlightedText) {
+        let path_string = path.to_string_lossy();
+        let path_char_count = path_string.chars().count();
+        // Get the subset of match highlight positions that line up with the given path.
+        // Also adjusts them to start at the path start
+        let path_positions = match_positions
+            .iter()
+            .copied()
+            .skip_while(|position| *position < path_start_offset)
+            .take_while(|position| *position < path_start_offset + path_char_count)
+            .map(|position| position - path_start_offset)
+            .collect::<Vec<_>>();
+
+        // Again subset the highlight positions to just those that line up with the file_name
+        // again adjusted to the start of the file_name
+        let file_name_text_and_positions = path.file_name().map(|file_name| {
+            let text = file_name.to_string_lossy();
+            let char_count = text.chars().count();
+            let file_name_start = path_char_count - char_count;
+            let highlight_positions = path_positions
+                .iter()
+                .copied()
+                .skip_while(|position| *position < file_name_start)
+                .take_while(|position| *position < file_name_start + char_count)
+                .map(|position| position - file_name_start)
+                .collect::<Vec<_>>();
+            HighlightedText {
+                text: text.to_string(),
+                highlight_positions,
+                char_count,
+            }
+        });
+
+        (
+            file_name_text_and_positions,
+            HighlightedText {
+                text: path_string.to_string(),
+                highlight_positions: path_positions,
+                char_count: path_char_count,
+            },
+        )
+    }
+}

crates/recent_projects/src/recent_projects.rs 🔗

@@ -0,0 +1,187 @@
+mod highlighted_workspace_location;
+
+use fuzzy::{StringMatch, StringMatchCandidate};
+use gpui::{
+    actions,
+    elements::{ChildView, Flex, ParentElement},
+    AnyViewHandle, Element, ElementBox, Entity, MutableAppContext, RenderContext, Task, View,
+    ViewContext, ViewHandle,
+};
+use highlighted_workspace_location::HighlightedWorkspaceLocation;
+use ordered_float::OrderedFloat;
+use picker::{Picker, PickerDelegate};
+use settings::Settings;
+use workspace::{OpenPaths, Workspace, WorkspaceLocation};
+
+const RECENT_LIMIT: usize = 100;
+
+actions!(recent_projects, [Toggle]);
+
+pub fn init(cx: &mut MutableAppContext) {
+    cx.add_action(RecentProjectsView::toggle);
+    Picker::<RecentProjectsView>::init(cx);
+}
+
+struct RecentProjectsView {
+    picker: ViewHandle<Picker<Self>>,
+    workspace_locations: Vec<WorkspaceLocation>,
+    selected_match_index: usize,
+    matches: Vec<StringMatch>,
+}
+
+impl RecentProjectsView {
+    fn new(cx: &mut ViewContext<Self>) -> Self {
+        let handle = cx.weak_handle();
+        let workspace_locations: Vec<WorkspaceLocation> = workspace::WORKSPACE_DB
+            .recent_workspaces(RECENT_LIMIT)
+            .unwrap_or_default()
+            .into_iter()
+            .map(|(_, location)| location)
+            .collect();
+        Self {
+            picker: cx.add_view(|cx| {
+                Picker::new("Recent Projects...", handle, cx).with_max_size(800., 1200.)
+            }),
+            workspace_locations,
+            selected_match_index: 0,
+            matches: Default::default(),
+        }
+    }
+
+    fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
+        workspace.toggle_modal(cx, |_, cx| {
+            let view = cx.add_view(|cx| Self::new(cx));
+            cx.subscribe(&view, Self::on_event).detach();
+            view
+        });
+    }
+
+    fn on_event(
+        workspace: &mut Workspace,
+        _: ViewHandle<Self>,
+        event: &Event,
+        cx: &mut ViewContext<Workspace>,
+    ) {
+        match event {
+            Event::Dismissed => workspace.dismiss_modal(cx),
+        }
+    }
+}
+
+pub enum Event {
+    Dismissed,
+}
+
+impl Entity for RecentProjectsView {
+    type Event = Event;
+}
+
+impl View for RecentProjectsView {
+    fn ui_name() -> &'static str {
+        "RecentProjectsView"
+    }
+
+    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+        ChildView::new(self.picker.clone(), cx).boxed()
+    }
+
+    fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
+        if cx.is_self_focused() {
+            cx.focus(&self.picker);
+        }
+    }
+}
+
+impl PickerDelegate for RecentProjectsView {
+    fn match_count(&self) -> usize {
+        self.matches.len()
+    }
+
+    fn selected_index(&self) -> usize {
+        self.selected_match_index
+    }
+
+    fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext<Self>) {
+        self.selected_match_index = ix;
+    }
+
+    fn update_matches(&mut self, query: String, cx: &mut ViewContext<Self>) -> gpui::Task<()> {
+        let query = query.trim_start();
+        let smart_case = query.chars().any(|c| c.is_uppercase());
+        let candidates = self
+            .workspace_locations
+            .iter()
+            .enumerate()
+            .map(|(id, location)| {
+                let combined_string = location
+                    .paths()
+                    .iter()
+                    .map(|path| path.to_string_lossy().to_owned())
+                    .collect::<Vec<_>>()
+                    .join("");
+                StringMatchCandidate::new(id, combined_string)
+            })
+            .collect::<Vec<_>>();
+        self.matches = smol::block_on(fuzzy::match_strings(
+            candidates.as_slice(),
+            query,
+            smart_case,
+            100,
+            &Default::default(),
+            cx.background().clone(),
+        ));
+        self.matches.sort_unstable_by_key(|m| m.candidate_id);
+
+        self.selected_match_index = self
+            .matches
+            .iter()
+            .enumerate()
+            .max_by_key(|(_, m)| OrderedFloat(m.score))
+            .map(|(ix, _)| ix)
+            .unwrap_or(0);
+        Task::ready(())
+    }
+
+    fn confirm(&mut self, cx: &mut ViewContext<Self>) {
+        let selected_match = &self.matches[self.selected_index()];
+        let workspace_location = &self.workspace_locations[selected_match.candidate_id];
+        cx.dispatch_global_action(OpenPaths {
+            paths: workspace_location.paths().as_ref().clone(),
+        });
+        cx.emit(Event::Dismissed);
+    }
+
+    fn dismiss(&mut self, cx: &mut ViewContext<Self>) {
+        cx.emit(Event::Dismissed);
+    }
+
+    fn render_match(
+        &self,
+        ix: usize,
+        mouse_state: &mut gpui::MouseState,
+        selected: bool,
+        cx: &gpui::AppContext,
+    ) -> ElementBox {
+        let settings = cx.global::<Settings>();
+        let string_match = &self.matches[ix];
+        let style = settings.theme.picker.item.style_for(mouse_state, selected);
+
+        let highlighted_location = HighlightedWorkspaceLocation::new(
+            &string_match,
+            &self.workspace_locations[string_match.candidate_id],
+        );
+
+        Flex::column()
+            .with_child(highlighted_location.names.render(style.label.clone()))
+            .with_children(
+                highlighted_location
+                    .paths
+                    .into_iter()
+                    .map(|highlighted_path| highlighted_path.render(style.label.clone())),
+            )
+            .flex(1., false)
+            .contained()
+            .with_style(style.container)
+            .named("match")
+    }
+}

crates/workspace/src/workspace.rs 🔗

@@ -50,7 +50,7 @@ pub use pane_group::*;
 use persistence::{model::SerializedItem, DB};
 pub use persistence::{
     model::{ItemId, WorkspaceLocation},
-    WorkspaceDb,
+    WorkspaceDb, DB as WORKSPACE_DB,
 };
 use postage::prelude::Stream;
 use project::{Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId};

crates/zed/Cargo.toml 🔗

@@ -44,6 +44,7 @@ plugin_runtime = { path = "../plugin_runtime" }
 project = { path = "../project" }
 project_panel = { path = "../project_panel" }
 project_symbols = { path = "../project_symbols" }
+recent_projects = { path = "../recent_projects" }
 rpc = { path = "../rpc" }
 settings = { path = "../settings" }
 sum_tree = { path = "../sum_tree" }

crates/zed/src/main.rs 🔗

@@ -121,6 +121,7 @@ fn main() {
         vim::init(cx);
         terminal::init(cx);
         theme_testbench::init(cx);
+        recent_projects::init(cx);
 
         cx.spawn(|cx| watch_themes(fs.clone(), themes.clone(), cx))
             .detach();