feat: rank fuzzy revision and reference searches in diff selectors

This commit is contained in:
Valdemar Lorenzen
2026-10-13 12:12:58 +02:00
parent 002f071b0f
commit 5382307fcc
4 changed files with 477 additions and 22 deletions
+2 -1
View File
@@ -22,7 +22,8 @@
- [x] diff view: make foreground of etched areas (where no lines exist) the same dimmed color as the normal gutter text - [x] diff view: make foreground of etched areas (where no lines exist) the same dimmed color as the normal gutter text
- [x] copying is pretty slow; optimize performance to reduce latency. - [x] copying is pretty slow; optimize performance to reduce latency.
- [x] enable margins in top/bottom bar elements; give the bottom bar's far-right position indicator a default leading margin of 1 column and no trailing margin. - [x] enable margins in top/bottom bar elements; give the bottom bar's far-right position indicator a default leading margin of 1 column and no trailing margin.
- [ ] better fuzzy search when searching revisions and bookmarks (or the git alternatives) in before and after picker for diff view. - [x] better fuzzy search when searching revisions and bookmarks (or the git alternatives) in before and after picker for diff view.
- [ ] git blame (enabled by default).
- [ ] buffer picker (with search). - [ ] buffer picker (with search).
- [ ] pull a pr description and display it in a buffer: - [ ] pull a pr description and display it in a buffer:
- open a selector that allows you to pick a pr from remote - open a selector that allows you to pick a pr from remote
+1 -1
View File
@@ -319,7 +319,7 @@ The project root is the working directory captured at startup. Scanning uses the
Run `:diff` in Normal mode, choose **Before**, then **After**, and press Enter to compare. The command palette also exposes **Diff**; there is no default keybinding. Repository discovery starts at the active file's directory, or the startup project root for an unnamed buffer, and prefers jj over Git. Run `:diff` in Normal mode, choose **Before**, then **After**, and press Enter to compare. The command palette also exposes **Diff**; there is no default keybinding. Repository discovery starts at the active file's directory, or the startup project root for an unnamed buffer, and prefers jj over Git.
The floating selector has **Revisions / Bookmarks** tabs for jj, or **Commits / Branches / Tags** for Git. Type to filter by revision/change ID, commit ID, reference name, or description. **Working checkout** stays first in every category, even when it does not match the query. As Before it is a disk snapshot, including eligible untracked files. As After it includes unsaved regular-text buffers and enables live editing; opening a comparison never saves them. The floating selector has **Revisions / Bookmarks** tabs for jj, or **Commits / Branches / Tags** for Git. Both Before and After support case-insensitive fuzzy search by revision/change ID, commit ID, reference name, or description: `f1205pas` can find `sm/fin-1205-pilot-assignment-schema`. Space-separated tokens must all match, but can match different fields in any order; a single token cannot span unrelated fields. Exact, prefix, and contiguous matches rank ahead of scattered subsequences; otherwise equal matches favor names/IDs over descriptions, word boundaries, and tighter spans. Ties retain catalogue order, and an empty or whitespace-only query restores the original order. **Working checkout** stays first in every category, even when it does not match the query. As Before it is a disk snapshot, including eligible untracked files. As After it includes unsaved regular-text buffers and enables live editing; opening a comparison never saves them.
New comparisons default to **Branch changes**: Before becomes the unique merge base of the selected Before and After revisions, and After stays the selected tip. Changes exclusive to the Before branch are excluded. The Before border identifies both selected names and the immutable merge-base commit ID. **Snapshots** compares the two selected snapshots directly. **Ctrl+b** toggles the mode, also available as **`:diff.mode.toggle`**, the palette action, and remappable **`config.core.diff.mode.toggle`**. Toggling an opened comparison recomputes using the original selected endpoints. Selecting **Working checkout** on either side immediately forces **Snapshots**; Branch changes is unavailable while it is selected. Missing or multiple merge bases produce an explicit, recoverable selector error rather than silently falling back; choose Snapshots to compare such histories. jj's synthetic root is the empty ancestor of every jj history. New comparisons default to **Branch changes**: Before becomes the unique merge base of the selected Before and After revisions, and After stays the selected tip. Changes exclusive to the Before branch are excluded. The Before border identifies both selected names and the immutable merge-base commit ID. **Snapshots** compares the two selected snapshots directly. **Ctrl+b** toggles the mode, also available as **`:diff.mode.toggle`**, the palette action, and remappable **`config.core.diff.mode.toggle`**. Toggling an opened comparison recomputes using the original selected endpoints. Selecting **Working checkout** on either side immediately forces **Snapshots**; Branch changes is unavailable while it is selected. Missing or multiple merge bases produce an explicit, recoverable selector error rather than silently falling back; choose Snapshots to compare such histories. jj's synthetic root is the empty ancestor of every jj history.
+181 -16
View File
@@ -234,7 +234,8 @@ pub struct DiffState {
pending: Option<Operation>, pending: Option<Operation>,
request_kind: Option<RequestKind>, request_kind: Option<RequestKind>,
visible: Vec<usize>, visible: Vec<usize>,
search_labels: Vec<String>, search_fields: Vec<[String; 3]>,
search_matches: Vec<([usize; 7], usize)>,
max_before: usize, max_before: usize,
max_after: usize, max_after: usize,
after_widths: Vec<usize>, after_widths: Vec<usize>,
@@ -279,7 +280,8 @@ impl DiffState {
pending: Some(Operation::Discover), pending: Some(Operation::Discover),
request_kind: Some(RequestKind::Catalog), request_kind: Some(RequestKind::Catalog),
visible: Vec::new(), visible: Vec::new(),
search_labels: Vec::new(), search_fields: Vec::new(),
search_matches: Vec::new(),
max_before: 0, max_before: 0,
max_after: 0, max_after: 0,
after_widths: Vec::new(), after_widths: Vec::new(),
@@ -344,7 +346,7 @@ impl DiffState {
.entries() .entries()
.nth(self.selected) .nth(self.selected)
.map(|entry| entry.target.clone()); .map(|entry| entry.target.clone());
self.search_labels = catalog self.search_fields = catalog
.entries .entries
.iter() .iter()
.map(|entry| { .map(|entry| {
@@ -352,7 +354,11 @@ impl DiffState {
RevisionTarget::Commit(id) => id.as_str(), 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(); .collect();
self.category = self.category.min(catalog.kind.categories().len() - 1); self.category = self.category.min(catalog.kind.categories().len() - 1);
@@ -431,6 +437,7 @@ impl DiffState {
fn refilter(&mut self) { fn refilter(&mut self) {
self.visible.clear(); self.visible.clear();
self.search_matches.clear();
if let Some(catalog) = &self.catalog { if let Some(catalog) = &self.catalog {
let category = catalog.kind.categories()[self.category]; let category = catalog.kind.categories()[self.category];
let query = self.query.to_lowercase(); let query = self.query.to_lowercase();
@@ -444,20 +451,47 @@ impl DiffState {
.filter(|(_, entry)| entry.target == RevisionTarget::Working) .filter(|(_, entry)| entry.target == RevisionTarget::Working)
.map(|(index, _)| index), .map(|(index, _)| index),
); );
self.visible.extend( for (index, entry) in catalog.entries.iter().enumerate() {
catalog if entry.target == RevisionTarget::Working || entry.category != category {
.entries 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() .iter()
.enumerate() .enumerate()
.filter(|(index, entry)| { .filter_map(|(field_index, field)| {
entry.target != RevisionTarget::Working revision_match(field, token).map(
&& entry.category == category |[kind, boundaries, gaps, start, len]| {
&& tokens [
.iter() kind,
.all(|token| self.search_labels[*index].contains(token)) usize::from(field_index == 2),
boundaries,
gaps,
start,
len,
]
},
)
}) })
.map(|(index, _)| index), .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; 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( fn append_rows(
rows: &mut Vec<AlignedRow>, rows: &mut Vec<AlignedRow>,
before: usize, before: usize,
@@ -2201,6 +2289,73 @@ mod tests {
assert_eq!(editor.diff().unwrap().focus, Focus::After); 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] #[test]
fn selector_filters_literal_unicode_tokens_and_pins_working_in_each_category() { fn selector_filters_literal_unicode_tokens_and_pins_working_in_each_category() {
let mut editor = editor(); let mut editor = editor();
@@ -2313,9 +2468,19 @@ mod tests {
assert_eq!(state.phase, Phase::After); assert_eq!(state.phase, Phase::After);
assert_eq!(state.query, "origin"); assert_eq!(state.query, "origin");
assert_eq!(state.category, 0); 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); assert_eq!(state.entries().len(), 3);
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::Down);
}
editor.handle(Input::Enter); editor.handle(Input::Enter);
let (id, operation) = editor.take_diff_request().unwrap(); let (id, operation) = editor.take_diff_request().unwrap();
assert!(matches!(operation, Operation::Compare { after, .. } assert!(matches!(operation, Operation::Compare { after, .. }
+289
View File
@@ -0,0 +1,289 @@
use std::{
fs,
path::{Path, PathBuf},
process::Command,
sync::atomic::{AtomicU64, Ordering},
thread,
time::{Duration, Instant},
};
use vamp::{
diff::{Category, Phase, RevisionTarget},
editor::Input,
session::EditorSession,
};
static NEXT: AtomicU64 = AtomicU64::new(0);
struct Fixture {
root: PathBuf,
target: String,
decoy: String,
}
impl Fixture {
fn new() -> Self {
let root = std::env::temp_dir().join(format!(
"vamp-diff-search-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed),
));
fs::create_dir_all(root.join("repo")).unwrap();
let mut fixture = Self {
root,
target: String::new(),
decoy: String::new(),
};
fixture.git(&["init", "-q", "--initial-branch=fixture-main"]);
fixture.commit("foundation", "base");
fixture.target = fixture.commit("Über parser target", "target");
fixture.decoy = fixture.commit(
&format!("release archaeology mentioning {}", fixture.target),
"decoy",
);
for name in [
"a-r_e_l_e_a_s_e",
"release-candidate",
"zrelease",
"tie-a",
"tie-b",
] {
fixture.git(&["branch", name, &fixture.decoy]);
fixture.git(&["tag", name, &fixture.decoy]);
}
for name in ["release", "feature/parser-rewrite", "feature/Über-parser"] {
fixture.git(&["branch", name, &fixture.target]);
fixture.git(&["tag", name, &fixture.target]);
}
fixture
}
fn git(&self, args: &[&str]) -> String {
let output = Command::new("git")
.args(args)
.current_dir(self.root.join("repo"))
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_AUTHOR_NAME", "Diff Search Fixture")
.env("GIT_AUTHOR_EMAIL", "fixture@example.invalid")
.env("GIT_COMMITTER_NAME", "Diff Search Fixture")
.env("GIT_COMMITTER_EMAIL", "fixture@example.invalid")
.env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z")
.env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z")
.output()
.unwrap();
assert!(
output.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap().trim().to_owned()
}
fn commit(&self, message: &str, content: &str) -> String {
fs::write(self.root.join("repo/source.txt"), content).unwrap();
self.git(&["add", "source.txt"]);
self.git(&["commit", "-qm", message]);
self.git(&["rev-parse", "HEAD"])
}
fn session(&self) -> EditorSession {
let config = self.root.join("config.rb");
fs::write(
&config,
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\nend\n",
)
.unwrap();
let mut session = EditorSession::open(
Some(self.root.join("repo/source.txt")),
vec![],
Some(&config),
)
.unwrap();
assert!(session.editor().startup_diagnostic().is_none());
session.execute(":diff");
wait_until(&mut session, |session| {
session
.editor()
.diff()
.is_some_and(|diff| diff.catalog.is_some() && !diff.loading)
});
session
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
fn wait_until(session: &mut EditorSession, ready: impl Fn(&EditorSession) -> bool) {
let deadline = Instant::now() + Duration::from_secs(10);
while !ready(session) {
session.poll();
assert!(Instant::now() < deadline, "{:?}", session.editor().diff());
if let Some(diff) = session.editor().diff() {
assert!(diff.error.is_none(), "{:?}", diff.error);
}
thread::sleep(Duration::from_millis(1));
}
}
fn query(session: &mut EditorSession, text: &str) {
session.handle(Input::Ctrl('u'));
for ch in text.chars() {
session.handle(Input::Char(ch));
}
}
fn category(session: &mut EditorSession, category: Category) {
query(session, "");
for _ in 0..3 {
let diff = session.editor().diff().unwrap();
if diff.catalog.as_ref().unwrap().kind.categories()[diff.category] == category {
return;
}
session.handle(Input::Char('\t'));
}
panic!("category unavailable: {category:?}");
}
fn labels(session: &EditorSession) -> Vec<String> {
let diff = session.editor().diff().unwrap();
assert_eq!(
diff.entries().next().unwrap().target,
RevisionTarget::Working
);
diff.entries()
.skip(1)
.map(|entry| entry.label.clone())
.collect()
}
#[test]
fn branch_and_tag_fuzzy_ranking_and_empty_order_match_in_both_selectors() {
let fixture = Fixture::new();
let mut session = fixture.session();
for phase in [Phase::Before, Phase::After] {
assert_eq!(session.editor().diff().unwrap().phase, phase);
for kind in [Category::Branches, Category::Tags] {
category(&mut session, kind);
let catalogue_order = labels(&session);
query(&mut session, " ");
assert_eq!(labels(&session), catalogue_order);
query(&mut session, "release");
assert_eq!(
labels(&session),
[
"release",
"release-candidate",
"zrelease",
"a-r_e_l_e_a_s_e"
]
);
query(&mut session, "ftprw");
assert_eq!(labels(&session), ["feature/parser-rewrite"]);
query(&mut session, "tie");
let ties: Vec<_> = catalogue_order
.iter()
.filter(|label| label.starts_with("tie-"))
.cloned()
.collect();
assert_eq!(&labels(&session)[..ties.len()], ties);
query(&mut session, "no-such-reference-anywhere");
assert!(labels(&session).is_empty());
assert_eq!(session.editor().diff().unwrap().selected, 0);
}
if phase == Phase::Before {
// Even an unmatched query leaves Working selectable and pinned first.
session.handle(Input::Enter);
}
}
}
#[test]
fn unicode_tokens_and_commit_ids_search_fields_without_cross_field_subsequences() {
let fixture = Fixture::new();
let mut session = fixture.session();
for phase in [Phase::Before, Phase::After] {
assert_eq!(session.editor().diff().unwrap().phase, phase);
category(&mut session, Category::Branches);
for text in [
format!("{} ÜBPR", &fixture.target[..10]),
format!("übpr {}", &fixture.target[..10]),
] {
query(&mut session, &text);
assert_eq!(labels(&session), ["feature/Über-parser"]);
}
category(&mut session, Category::Commits);
for text in [&fixture.target[..], &fixture.target[..10]] {
query(&mut session, text);
assert_eq!(
labels(&session),
[fixture.target.clone(), fixture.decoy.clone()]
);
}
query(&mut session, "ÜB PRSR");
assert_eq!(labels(&session), [fixture.target.clone()]);
// The ID and description can satisfy separate tokens, but not one token
// that would require a subsequence crossing between unrelated fields.
query(&mut session, &format!("{} übpr", &fixture.target[..10]));
assert_eq!(labels(&session), [fixture.target.clone()]);
query(&mut session, &format!("{}über", fixture.target));
assert!(labels(&session).is_empty());
if phase == Phase::Before {
session.handle(Input::Enter);
}
}
}
#[test]
fn selecting_ranked_branch_uses_its_actual_commit_in_before_and_after() {
let fixture = Fixture::new();
for ranked_phase in [Phase::Before, Phase::After] {
let mut session = fixture.session();
if ranked_phase == Phase::After {
session.handle(Input::Enter);
}
category(&mut session, Category::Branches);
query(&mut session, "release");
assert_eq!(labels(&session)[0], "release");
session.handle(Input::Down);
session.handle(Input::Enter);
if ranked_phase == Phase::Before {
// Working selects snapshot mode rather than merge-base mode.
session.handle(Input::Enter);
}
wait_until(&mut session, |session| {
session
.editor()
.diff()
.is_some_and(|diff| diff.phase == Phase::View && diff.selected_file_loaded())
});
let diff = session.editor().diff().unwrap();
let ranked = if ranked_phase == Phase::Before {
diff.before.as_ref().unwrap()
} else {
diff.after.as_ref().unwrap()
};
assert_eq!(ranked.label, "release");
assert_eq!(
ranked.target,
RevisionTarget::Commit(fixture.target.clone())
);
let comparison = diff.comparison.as_ref().unwrap();
assert_eq!(comparison.files.len(), 1);
assert_eq!(comparison.files[0].path, Path::new("source.txt"));
let (before, after) = if ranked_phase == Phase::Before {
(["target"], ["decoy"])
} else {
(["decoy"], ["target"])
};
assert_eq!(comparison.files[0].before, before);
assert_eq!(comparison.files[0].after, after);
}
}