feat: reuse fuzzy search in the file picker
This commit is contained in:
+16
-72
@@ -1,5 +1,5 @@
|
||||
use crate::editor::{BackspaceKind, Input};
|
||||
use crate::files::{FilePicker, FilePresentation, FileView};
|
||||
use crate::files::{FilePicker, FilePresentation, FileView, fuzzy_match};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
path::{Path, PathBuf},
|
||||
@@ -963,18 +963,16 @@ impl DiffState {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(field_index, field)| {
|
||||
revision_match(field, token).map(
|
||||
|[kind, boundaries, gaps, start, len]| {
|
||||
[
|
||||
kind,
|
||||
usize::from(field_index == 2),
|
||||
boundaries,
|
||||
gaps,
|
||||
start,
|
||||
len,
|
||||
]
|
||||
},
|
||||
)
|
||||
fuzzy_match(field, token).map(|[kind, boundaries, gaps, start, len]| {
|
||||
[
|
||||
kind,
|
||||
usize::from(field_index == 2),
|
||||
boundaries,
|
||||
gaps,
|
||||
start,
|
||||
len,
|
||||
]
|
||||
})
|
||||
})
|
||||
.min()?;
|
||||
// A scattered token never outranks an all-contiguous match.
|
||||
@@ -2168,60 +2166,6 @@ impl DiffState {
|
||||
}
|
||||
}
|
||||
|
||||
// Match class, missed word/reference boundaries, gaps, start, field length.
|
||||
fn revision_match(field: &str, token: &str) -> Option<[usize; 5]> {
|
||||
if field == token {
|
||||
return Some([0, 0, 0, 0, field.len()]);
|
||||
}
|
||||
if field.starts_with(token) {
|
||||
return Some([1, 0, 0, 0, field.len()]);
|
||||
}
|
||||
let contiguous = field
|
||||
.match_indices(token)
|
||||
.map(|(start, _)| {
|
||||
let boundary = field[..start]
|
||||
.chars()
|
||||
.next_back()
|
||||
.is_none_or(|ch| !ch.is_alphanumeric());
|
||||
[2, usize::from(!boundary), 0, start, field.len()]
|
||||
})
|
||||
.min();
|
||||
if contiguous.is_some() {
|
||||
return contiguous;
|
||||
}
|
||||
|
||||
// ponytail: greedy subsequences can miss tighter later alignments; use dynamic
|
||||
// programming if that ranking precision becomes necessary.
|
||||
let mut wanted = token.chars();
|
||||
let mut next = wanted.next()?;
|
||||
let mut start = 0;
|
||||
let mut matched = 0;
|
||||
let mut boundaries = 0;
|
||||
let mut previous_is_word = false;
|
||||
for (position, ch) in field.chars().enumerate() {
|
||||
if ch == next {
|
||||
if matched == 0 {
|
||||
start = position;
|
||||
}
|
||||
matched += 1;
|
||||
boundaries += usize::from(previous_is_word);
|
||||
if let Some(ch) = wanted.next() {
|
||||
next = ch;
|
||||
} else {
|
||||
return Some([
|
||||
3,
|
||||
boundaries,
|
||||
position - start + 1 - matched,
|
||||
start,
|
||||
field.len(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
previous_is_word = ch.is_alphanumeric();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn append_rows(
|
||||
rows: &mut Vec<AlignedRow>,
|
||||
before: usize,
|
||||
@@ -3468,11 +3412,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn revision_matches_rank_later_contiguous_boundaries_and_tight_subsequences() {
|
||||
assert_eq!(revision_match("r---f---s/topic/rfs", "rfs").unwrap()[0], 2);
|
||||
assert!(revision_match("topic/rfs", "rfs") < revision_match("topicxrfs", "rfs"));
|
||||
assert!(revision_match("r-f-s", "rfs") < revision_match("r---f---s", "rfs"));
|
||||
assert!(revision_match("über-parser", "üpr").is_some());
|
||||
assert!(revision_match("parser", "srp").is_none());
|
||||
assert_eq!(fuzzy_match("r---f---s/topic/rfs", "rfs").unwrap()[0], 2);
|
||||
assert!(fuzzy_match("topic/rfs", "rfs") < fuzzy_match("topicxrfs", "rfs"));
|
||||
assert!(fuzzy_match("r-f-s", "rfs") < fuzzy_match("r---f---s", "rfs"));
|
||||
assert!(fuzzy_match("über-parser", "üpr").is_some());
|
||||
assert!(fuzzy_match("parser", "srp").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+109
-1
@@ -105,6 +105,60 @@ pub struct FilePicker {
|
||||
has_virtual_entries: bool,
|
||||
}
|
||||
|
||||
// Lowercased field/token. Score: match class, missed word boundaries, gaps, start, length.
|
||||
pub(crate) fn fuzzy_match(field: &str, token: &str) -> Option<[usize; 5]> {
|
||||
if field == token {
|
||||
return Some([0, 0, 0, 0, field.len()]);
|
||||
}
|
||||
if field.starts_with(token) {
|
||||
return Some([1, 0, 0, 0, field.len()]);
|
||||
}
|
||||
let contiguous = field
|
||||
.match_indices(token)
|
||||
.map(|(start, _)| {
|
||||
let boundary = field[..start]
|
||||
.chars()
|
||||
.next_back()
|
||||
.is_none_or(|ch| !ch.is_alphanumeric());
|
||||
[2, usize::from(!boundary), 0, start, field.len()]
|
||||
})
|
||||
.min();
|
||||
if contiguous.is_some() {
|
||||
return contiguous;
|
||||
}
|
||||
|
||||
// ponytail: greedy subsequences can miss tighter later alignments; use dynamic
|
||||
// programming if that ranking precision becomes necessary.
|
||||
let mut wanted = token.chars();
|
||||
let mut next = wanted.next()?;
|
||||
let mut start = 0;
|
||||
let mut matched = 0;
|
||||
let mut boundaries = 0;
|
||||
let mut previous_is_word = false;
|
||||
for (position, ch) in field.chars().enumerate() {
|
||||
if ch == next {
|
||||
if matched == 0 {
|
||||
start = position;
|
||||
}
|
||||
matched += 1;
|
||||
boundaries += usize::from(previous_is_word);
|
||||
if let Some(ch) = wanted.next() {
|
||||
next = ch;
|
||||
} else {
|
||||
return Some([
|
||||
3,
|
||||
boundaries,
|
||||
position - start + 1 - matched,
|
||||
start,
|
||||
field.len(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
previous_is_word = ch.is_alphanumeric();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn label(path: &Path) -> String {
|
||||
path.to_string_lossy()
|
||||
.chars()
|
||||
@@ -566,7 +620,11 @@ impl FilePicker {
|
||||
self.viewport_revision = NEXT_REVISION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let query = self.query.to_lowercase();
|
||||
let tokens: Vec<_> = query.split_whitespace().collect();
|
||||
let matches = |node: &Node| tokens.iter().all(|token| node.search_label.contains(token));
|
||||
let matches = |node: &Node| {
|
||||
tokens
|
||||
.iter()
|
||||
.all(|token| fuzzy_match(&node.search_label, token).is_some())
|
||||
};
|
||||
self.rows.clear();
|
||||
if self.view == FileView::List {
|
||||
self.rows
|
||||
@@ -1096,6 +1154,56 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_path_tokens_preserve_tree_context_selection_and_open_paths() {
|
||||
let directory = Directory::new();
|
||||
let names = [
|
||||
"src/fuzzy_parser.rs",
|
||||
"src/über_parser.rs",
|
||||
"src/other.rs",
|
||||
"tests/fuzzy_parser.rs",
|
||||
];
|
||||
for name in names {
|
||||
directory.put(name, "");
|
||||
}
|
||||
for mut picker in [
|
||||
directory.scan(),
|
||||
FilePicker::from_paths(
|
||||
directory.0.clone(),
|
||||
names.map(PathBuf::from).into_iter().enumerate(),
|
||||
),
|
||||
] {
|
||||
for view in [FileView::Tree, FileView::List] {
|
||||
picker.set_view(view);
|
||||
for (query, expected) in [
|
||||
("sr/fp RS", "src/fuzzy_parser.rs"),
|
||||
("ÜPR SRC", "src/über_parser.rs"),
|
||||
] {
|
||||
picker.query = query.into();
|
||||
picker.refilter();
|
||||
assert_eq!(
|
||||
picker
|
||||
.entries()
|
||||
.filter(|row| !row.is_dir)
|
||||
.map(|row| row.path)
|
||||
.collect::<Vec<_>>(),
|
||||
[Path::new(expected)]
|
||||
);
|
||||
assert_eq!(selected_path(&picker), Path::new(expected));
|
||||
assert_eq!(picker.accept(), Some(directory.0.join(expected)));
|
||||
if view == FileView::Tree {
|
||||
assert_eq!(paths(&picker), [Path::new("src"), Path::new(expected)]);
|
||||
}
|
||||
}
|
||||
picker.query = "rs/ü".into();
|
||||
picker.refilter();
|
||||
assert!(picker.entries().next().is_none());
|
||||
picker.clear_filter();
|
||||
assert_eq!(selected_path(&picker), Path::new("src/über_parser.rs"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanned_files_and_directories_have_no_catalog_index() {
|
||||
let directory = Directory::new();
|
||||
|
||||
Reference in New Issue
Block a user