feat: rank fuzzy revision and reference searches in diff selectors
This commit is contained in:
+185
-20
@@ -234,7 +234,8 @@ pub struct DiffState {
|
||||
pending: Option<Operation>,
|
||||
request_kind: Option<RequestKind>,
|
||||
visible: Vec<usize>,
|
||||
search_labels: Vec<String>,
|
||||
search_fields: Vec<[String; 3]>,
|
||||
search_matches: Vec<([usize; 7], usize)>,
|
||||
max_before: usize,
|
||||
max_after: usize,
|
||||
after_widths: Vec<usize>,
|
||||
@@ -279,7 +280,8 @@ impl DiffState {
|
||||
pending: Some(Operation::Discover),
|
||||
request_kind: Some(RequestKind::Catalog),
|
||||
visible: Vec::new(),
|
||||
search_labels: Vec::new(),
|
||||
search_fields: Vec::new(),
|
||||
search_matches: Vec::new(),
|
||||
max_before: 0,
|
||||
max_after: 0,
|
||||
after_widths: Vec::new(),
|
||||
@@ -344,7 +346,7 @@ impl DiffState {
|
||||
.entries()
|
||||
.nth(self.selected)
|
||||
.map(|entry| entry.target.clone());
|
||||
self.search_labels = catalog
|
||||
self.search_fields = catalog
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
@@ -352,7 +354,11 @@ impl DiffState {
|
||||
RevisionTarget::Commit(id) => id.as_str(),
|
||||
_ => "",
|
||||
};
|
||||
format!("{} {} {id}", entry.label, entry.description).to_lowercase()
|
||||
[
|
||||
entry.label.to_lowercase(),
|
||||
id.to_lowercase(),
|
||||
entry.description.to_lowercase(),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
self.category = self.category.min(catalog.kind.categories().len() - 1);
|
||||
@@ -431,6 +437,7 @@ impl DiffState {
|
||||
|
||||
fn refilter(&mut self) {
|
||||
self.visible.clear();
|
||||
self.search_matches.clear();
|
||||
if let Some(catalog) = &self.catalog {
|
||||
let category = catalog.kind.categories()[self.category];
|
||||
let query = self.query.to_lowercase();
|
||||
@@ -444,20 +451,47 @@ impl DiffState {
|
||||
.filter(|(_, entry)| entry.target == RevisionTarget::Working)
|
||||
.map(|(index, _)| index),
|
||||
);
|
||||
self.visible.extend(
|
||||
catalog
|
||||
.entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(index, entry)| {
|
||||
entry.target != RevisionTarget::Working
|
||||
&& entry.category == category
|
||||
&& tokens
|
||||
.iter()
|
||||
.all(|token| self.search_labels[*index].contains(token))
|
||||
})
|
||||
.map(|(index, _)| index),
|
||||
);
|
||||
for (index, entry) in catalog.entries.iter().enumerate() {
|
||||
if entry.target == RevisionTarget::Working || entry.category != category {
|
||||
continue;
|
||||
}
|
||||
if tokens.is_empty() {
|
||||
self.visible.push(index);
|
||||
continue;
|
||||
}
|
||||
let score = tokens.iter().try_fold([0usize; 7], |mut score, token| {
|
||||
let best = self.search_fields[index]
|
||||
.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,
|
||||
]
|
||||
},
|
||||
)
|
||||
})
|
||||
.min()?;
|
||||
// A scattered token never outranks an all-contiguous match.
|
||||
score[0] = score[0].max(best[0]);
|
||||
for (total, value) in score[1..].iter_mut().zip(best) {
|
||||
*total += value;
|
||||
}
|
||||
Some(score)
|
||||
});
|
||||
if let Some(score) = score {
|
||||
self.search_matches.push((score, index));
|
||||
}
|
||||
}
|
||||
self.search_matches.sort_unstable();
|
||||
self.visible
|
||||
.extend(self.search_matches.iter().map(|(_, index)| *index));
|
||||
}
|
||||
self.selected = 0;
|
||||
}
|
||||
@@ -1473,6 +1507,60 @@ 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,
|
||||
@@ -2201,6 +2289,73 @@ mod tests {
|
||||
assert_eq!(editor.diff().unwrap().focus, Focus::After);
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jujutsu_fuzzy_selectors_rank_labels_above_descriptions_in_both_stages() {
|
||||
for phase in [Phase::Before, Phase::After] {
|
||||
for (category_index, &category) in RepoKind::Jujutsu.categories().iter().enumerate() {
|
||||
let mut state = DiffState::new();
|
||||
state.apply_result(
|
||||
1,
|
||||
Ok(Outcome::Catalog(Catalog {
|
||||
kind: RepoKind::Jujutsu,
|
||||
entries: vec![
|
||||
revision(
|
||||
RevisionTarget::Working,
|
||||
"Working checkout",
|
||||
"",
|
||||
Category::Revisions,
|
||||
),
|
||||
revision(
|
||||
RevisionTarget::Commit("description-target".into()),
|
||||
"unrelated",
|
||||
"sm/fin-1205-pilot-assignment-schema",
|
||||
category,
|
||||
),
|
||||
revision(
|
||||
RevisionTarget::Commit("immutable-fuzzy-target".into()),
|
||||
"sm/fin-1205-pilot-assignment-schema",
|
||||
"",
|
||||
category,
|
||||
),
|
||||
revision(
|
||||
RevisionTarget::Commit("immutable-exact-target".into()),
|
||||
"f1205pas",
|
||||
"",
|
||||
category,
|
||||
),
|
||||
],
|
||||
notice: None,
|
||||
})),
|
||||
);
|
||||
state.phase = phase;
|
||||
state.category = category_index;
|
||||
state.query = "F1205PAS".into();
|
||||
state.refilter();
|
||||
assert_eq!(
|
||||
state
|
||||
.entries()
|
||||
.skip(1)
|
||||
.map(|entry| &entry.target)
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
&RevisionTarget::Commit("immutable-exact-target".into()),
|
||||
&RevisionTarget::Commit("immutable-fuzzy-target".into()),
|
||||
&RevisionTarget::Commit("description-target".into()),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_filters_literal_unicode_tokens_and_pins_working_in_each_category() {
|
||||
let mut editor = editor();
|
||||
@@ -2313,9 +2468,19 @@ mod tests {
|
||||
assert_eq!(state.phase, Phase::After);
|
||||
assert_eq!(state.query, "origin");
|
||||
assert_eq!(state.category, 0);
|
||||
assert_eq!(state.selected, 1);
|
||||
assert_eq!(
|
||||
state.entries().nth(state.selected).unwrap().target,
|
||||
RevisionTarget::FetchRemote("origin".into())
|
||||
);
|
||||
assert_eq!(state.entries().len(), 3);
|
||||
editor.handle(Input::Down);
|
||||
let remote_position = state
|
||||
.entries()
|
||||
.position(|entry| matches!(&entry.target, RevisionTarget::Commit(id) if id == "remote-commit"))
|
||||
.unwrap();
|
||||
editor.handle(Input::Home);
|
||||
for _ in 0..remote_position {
|
||||
editor.handle(Input::Down);
|
||||
}
|
||||
editor.handle(Input::Enter);
|
||||
let (id, operation) = editor.take_diff_request().unwrap();
|
||||
assert!(matches!(operation, Operation::Compare { after, .. }
|
||||
|
||||
Reference in New Issue
Block a user