From 2bc685281c11c9cc01219608dffcc193e9cf9e45 Mon Sep 17 00:00:00 2001 From: Kay Simmons Date: Wed, 14 Dec 2022 15:59:50 -0800 Subject: [PATCH] Add recent project picker --- 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 + .../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, 1170 insertions(+), 797 deletions(-) create mode 100644 crates/fuzzy/src/matcher.rs create mode 100644 crates/fuzzy/src/paths.rs create mode 100644 crates/fuzzy/src/strings.rs create mode 100644 crates/recent_projects/Cargo.toml create mode 100644 crates/recent_projects/src/highlighted_workspace_location.rs create mode 100644 crates/recent_projects/src/recent_projects.rs diff --git a/Cargo.lock b/Cargo.lock index 57f2bdbdc4..d496370907 100644 --- a/Cargo.lock +++ b/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", diff --git a/Cargo.toml b/Cargo.toml index c4f54d6a90..1ace51dbd5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ members = [ "crates/project", "crates/project_panel", "crates/project_symbols", + "crates/recent_projects", "crates/rope", "crates/rpc", "crates/search", diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 5122a46c2c..7561a68222 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/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, String, Vec) { - 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, diff --git a/crates/fuzzy/src/fuzzy.rs b/crates/fuzzy/src/fuzzy.rs index 2f108b6274..4968023644 100644 --- a/crates/fuzzy/src/fuzzy.rs +++ b/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, - last_positions: Vec, - score_matrix: Vec>, - best_position_matrix: Vec, -} - -trait Match: Ord { - fn score(&self) -> f64; - fn set_positions(&mut self, positions: Vec); -} - -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, - pub char_bag: CharBag, -} - -#[derive(Clone, Debug)] -pub struct PathMatch { - pub score: f64, - pub positions: Vec, - pub worktree_id: usize, - pub path: Arc, - pub path_prefix: Arc, -} - -#[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>; - fn id(&self) -> usize; - fn len(&self) -> usize; - fn is_empty(&self) -> bool { - self.len() == 0 - } - fn prefix(&self) -> Arc; - 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) { - self.positions = positions; - } -} - -impl Match for StringMatch { - fn score(&self) -> f64 { - self.score - } - - fn set_positions(&mut self, positions: Vec) { - 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, - 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 { - 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 { - 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, -) -> Vec { - 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::>(); - let query = query.chars().collect::>(); - - 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::>(); - - 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, -) -> Vec { - 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::>(); - let query = query.chars().collect::>(); - - 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::>(); - - 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, - 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, - path_entries: impl Iterator>, - results: &mut Vec, - cancel_flag: &AtomicBool, - ) { - let prefix = path_prefix.chars().collect::>(); - let lowercase_prefix = prefix - .iter() - .map(|c| c.to_ascii_lowercase()) - .collect::>(); - 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( - &mut self, - prefix: &[char], - lowercase_prefix: &[char], - candidates: impl Iterator, - results: &mut Vec, - 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)> { - let lowercase_query = query.to_lowercase().chars().collect::>(); - let query = query.chars().collect::>(); - let query_chars = CharBag::from(&lowercase_query[..]); - - let path_arcs = paths - .iter() - .map(|path| Arc::from(PathBuf::from(path))) - .collect::>(); - let mut path_entries = Vec::new(); - for (i, path) in paths.iter().enumerate() { - let lowercase_path = path.to_lowercase().chars().collect::>(); - 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}; diff --git a/crates/fuzzy/src/matcher.rs b/crates/fuzzy/src/matcher.rs new file mode 100644 index 0000000000..51ae75bac2 --- /dev/null +++ b/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, + last_positions: Vec, + score_matrix: Vec>, + best_position_matrix: Vec, +} + +pub trait Match: Ord { + fn score(&self) -> f64; + fn set_positions(&mut self, positions: Vec); +} + +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( + &mut self, + prefix: &[char], + lowercase_prefix: &[char], + candidates: impl Iterator, + results: &mut Vec, + 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)> { + let lowercase_query = query.to_lowercase().chars().collect::>(); + let query = query.chars().collect::>(); + let query_chars = CharBag::from(&lowercase_query[..]); + + let path_arcs: Vec> = paths + .iter() + .map(|path| Arc::from(PathBuf::from(path))) + .collect::>(); + let mut path_entries = Vec::new(); + for (i, path) in paths.iter().enumerate() { + let lowercase_path = path.to_lowercase().chars().collect::>(); + 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() + } +} diff --git a/crates/fuzzy/src/paths.rs b/crates/fuzzy/src/paths.rs new file mode 100644 index 0000000000..8d9ec97d9b --- /dev/null +++ b/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, + pub char_bag: CharBag, +} + +#[derive(Clone, Debug)] +pub struct PathMatch { + pub score: f64, + pub positions: Vec, + pub worktree_id: usize, + pub path: Arc, + pub path_prefix: Arc, +} + +pub trait PathMatchCandidateSet<'a>: Send + Sync { + type Candidates: Iterator>; + fn id(&self) -> usize; + fn len(&self) -> usize; + fn is_empty(&self) -> bool { + self.len() == 0 + } + fn prefix(&self) -> Arc; + 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) { + 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 { + 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, +) -> Vec { + 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::>(); + let query = query.chars().collect::>(); + + 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::>(); + + 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::>(); + let lowercase_prefix = prefix + .iter() + .map(|c| c.to_ascii_lowercase()) + .collect::>(); + 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 +} diff --git a/crates/fuzzy/src/strings.rs b/crates/fuzzy/src/strings.rs new file mode 100644 index 0000000000..37ee20f528 --- /dev/null +++ b/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) { + 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, + 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 { + 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, +) -> Vec { + 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::>(); + let query = query.chars().collect::>(); + + 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::>(); + + 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 +} diff --git a/crates/outline/src/outline.rs b/crates/outline/src/outline.rs index f6698e23be..52b168b70c 100644 --- a/crates/outline/src/outline.rs +++ b/crates/outline/src/outline.rs @@ -84,13 +84,13 @@ impl OutlineView { .active_item(cx) .and_then(|item| item.downcast::()) { - let buffer = editor + let outline = editor .read(cx) .buffer() .read(cx) .snapshot(cx) .outline(Some(cx.global::().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(); diff --git a/crates/recent_projects/Cargo.toml b/crates/recent_projects/Cargo.toml new file mode 100644 index 0000000000..d633381365 --- /dev/null +++ b/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" diff --git a/crates/recent_projects/src/highlighted_workspace_location.rs b/crates/recent_projects/src/highlighted_workspace_location.rs new file mode 100644 index 0000000000..8e75b291a0 --- /dev/null +++ b/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, + char_count: usize, +} + +impl HighlightedText { + fn join(components: impl Iterator, 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) -> ElementBox { + Label::new(self.text, style) + .with_highlights(self.highlight_positions) + .boxed() + } +} + +pub struct HighlightedWorkspaceLocation { + pub names: HighlightedText, + pub paths: Vec, +} + +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, + path_start_offset: usize, + ) -> (Option, 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::>(); + + // 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::>(); + 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, + }, + ) + } +} diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs new file mode 100644 index 0000000000..1842540db3 --- /dev/null +++ b/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::::init(cx); +} + +struct RecentProjectsView { + picker: ViewHandle>, + workspace_locations: Vec, + selected_match_index: usize, + matches: Vec, +} + +impl RecentProjectsView { + fn new(cx: &mut ViewContext) -> Self { + let handle = cx.weak_handle(); + let workspace_locations: Vec = 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.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, + event: &Event, + cx: &mut ViewContext, + ) { + 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) -> ElementBox { + ChildView::new(self.picker.clone(), cx).boxed() + } + + fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { + 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.selected_match_index = ix; + } + + fn update_matches(&mut self, query: String, cx: &mut ViewContext) -> 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::>() + .join(""); + StringMatchCandidate::new(id, combined_string) + }) + .collect::>(); + 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) { + 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) { + 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::(); + 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") + } +} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index a0c353b3f8..7d37298e0b 100644 --- a/crates/workspace/src/workspace.rs +++ b/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}; diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index cc81f3bf23..3dde95358f 100644 --- a/crates/zed/Cargo.toml +++ b/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" } diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 4163841d45..c06023086f 100644 --- a/crates/zed/src/main.rs +++ b/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();