feat: show jj ancestry in the floating diff picker

This commit is contained in:
Valdemar Lorenzen
2026-10-23 17:08:53 +02:00
parent 496c93774f
commit e3d44f24c0
16 changed files with 882 additions and 50 deletions
-4
View File
@@ -629,10 +629,6 @@ fn branch_changes_default_and_mode_switches_compare_the_selected_divergent_tips(
session.editor().diff().unwrap().comparison_mode,
ComparisonMode::Snapshots
);
assert_eq!(
session.editor().message(),
"Working checkout requires Snapshots"
);
}
#[test]
+25
View File
@@ -1367,6 +1367,31 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang
);
assert_eq!(changed(&comparison, Path::new("file")).after, ["local"]);
let loaded = repository.fetch_remote("origin").unwrap();
assert_eq!(loaded.graph[&tip].parents, [ancestor.clone()]);
assert_eq!(
loaded.graph[&ancestor].description,
"remote historic needle"
);
assert!(!loaded.graph[&tip].current);
let positions: std::collections::BTreeMap<_, _> = loaded
.entries
.iter()
.filter(|entry| entry.category == Category::Revisions)
.enumerate()
.filter_map(|(index, entry)| match &entry.target {
RevisionTarget::Commit(id) => Some((id.as_str(), index)),
_ => None,
})
.collect();
for (id, metadata) in &loaded.graph {
for parent in &metadata.parents {
if let (Some(child), Some(parent)) =
(positions.get(id.as_str()), positions.get(parent.as_str()))
{
assert!(child < parent);
}
}
}
let historic = loaded
.entries
.iter()
+176 -4
View File
@@ -394,6 +394,7 @@ fn revision(
fn catalog() -> Catalog {
Catalog {
kind: RepoKind::Git,
graph: Default::default(),
entries: vec![
revision(
RevisionTarget::Working,
@@ -1573,7 +1574,7 @@ fn revision_matches_rank_later_contiguous_boundaries_and_tight_subsequences() {
}
#[test]
fn jujutsu_fuzzy_selectors_rank_labels_above_descriptions_in_both_stages() {
fn jujutsu_fuzzy_selectors_preserve_catalog_order_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();
@@ -1581,6 +1582,7 @@ fn jujutsu_fuzzy_selectors_rank_labels_above_descriptions_in_both_stages() {
1,
Ok(Outcome::Catalog(Catalog {
kind: RepoKind::Jujutsu,
graph: Default::default(),
entries: vec![
revision(
RevisionTarget::Working,
@@ -1621,15 +1623,185 @@ fn jujutsu_fuzzy_selectors_rank_labels_above_descriptions_in_both_stages() {
.map(|entry| &entry.target)
.collect::<Vec<_>>(),
[
&RevisionTarget::Commit("immutable-exact-target".into()),
&RevisionTarget::Commit("immutable-fuzzy-target".into()),
&RevisionTarget::Commit("description-target".into()),
&RevisionTarget::Commit("immutable-fuzzy-target".into()),
&RevisionTarget::Commit("immutable-exact-target".into()),
]
);
}
}
}
fn graph_state() -> DiffState {
let mut catalog = Catalog {
kind: RepoKind::Jujutsu,
graph: Default::default(),
notice: None,
entries: vec![revision(
RevisionTarget::Working,
"Working",
"",
Category::Revisions,
)],
};
for (id, parents) in [
("merge", vec!["left", "right"]),
("left", vec!["hidden"]),
("hidden", vec!["root"]),
("right", vec!["root"]),
("root", vec![]),
] {
catalog.entries.push(revision(
RevisionTarget::Commit(id.into()),
id,
"needle",
Category::Revisions,
));
catalog.graph.insert(
id.into(),
RevisionGraph {
parents: parents.into_iter().map(String::from).collect(),
..Default::default()
},
);
}
catalog.entries.push(revision(
RevisionTarget::Bookmark {
name: "topic".into(),
remote: None,
},
"topic",
"change merge needle",
Category::Bookmarks,
));
catalog.entries.push(revision(
RevisionTarget::Bookmark {
name: "base".into(),
remote: None,
},
"base",
"change root needle",
Category::Bookmarks,
));
let mut state = DiffState::new();
state.apply_result(1, Ok(Outcome::Catalog(catalog)));
state
}
#[test]
fn picker_graph_preserves_forks_merges_and_marks_filtered_ancestry() {
let mut state = graph_state();
let graph = state.picker_graph();
assert_eq!(graph.len(), 5);
assert_eq!(graph["merge"][1].trim_end(), "├─┐");
assert_eq!(graph["right"][1].trim_end(), "├─┘");
assert!(!graph.values().flatten().any(|prefix| prefix.contains('~')));
state.category = 1;
state.refilter();
assert_eq!(state.entries().len(), 3);
let graph = state.picker_graph();
assert_eq!(
graph.keys().map(String::as_str).collect::<Vec<_>>(),
["merge", "root"]
);
assert!(graph["merge"][1].contains('~'));
assert_eq!(
state
.graph_revision(state.entries().nth(1).unwrap())
.unwrap()
.0,
"merge"
);
assert!(matches!(
state.entries().nth(1).unwrap().target,
RevisionTarget::Bookmark { .. }
));
state.category = 0;
state.query = "left root".into();
state.refilter();
assert!(state.picker_graph().is_empty());
assert_eq!(state.entries().len(), 1);
state.before = Some(state.catalog.as_ref().unwrap().entries[1].clone());
state.query = "right".into();
state.refilter();
assert_eq!(state.picker_graph().len(), 2);
assert!(state.picker_graph()["merge"][1].contains('~'));
}
#[test]
fn graph_crossings_do_not_join_unrelated_ancestry() {
let mut state = DiffState::new();
let mut catalog = Catalog {
kind: RepoKind::Jujutsu,
entries: Vec::new(),
graph: Default::default(),
notice: None,
};
for (id, parents) in [
("a", vec!["x"]),
("b", vec!["y"]),
("merge", vec!["x", "z"]),
("x", vec![]),
("y", vec![]),
("z", vec![]),
] {
catalog.entries.push(revision(
RevisionTarget::Commit(id.into()),
id,
"",
Category::Revisions,
));
catalog.graph.insert(
id.into(),
RevisionGraph {
parents: parents.into_iter().map(String::from).collect(),
..Default::default()
},
);
}
state.apply_result(1, Ok(Outcome::Catalog(catalog)));
assert_eq!(state.picker_graph()["merge"][1].trim_end(), "├─╪─┤");
}
#[test]
fn before_identity_survives_query_and_category_without_duplicate_graph_nodes() {
let mut state = graph_state();
state.category = 1;
state.refilter();
state.selected = 1;
state.choose();
let before = state.before.clone().unwrap();
assert!(matches!(before.target, RevisionTarget::Bookmark { .. }));
state.category = 0;
state.refilter();
assert_eq!(
state
.entries()
.filter(|entry| state
.graph_revision(entry)
.is_some_and(|(id, _)| id == "merge"))
.count(),
1
);
for category in 0..2 {
state.category = category;
state.query = "unmatched".into();
state.refilter();
assert_eq!(state.entries().len(), 2);
let retained = state
.entries()
.find(|entry| entry.target == before.target)
.unwrap();
assert_eq!(retained.target, before.target);
assert_eq!(state.before.as_ref(), Some(&before));
state.selected = 1;
assert_eq!(
state.entries().nth(state.selected).unwrap().target,
before.target
);
}
}
#[test]
fn selector_filters_literal_unicode_tokens_and_pins_working_in_each_category() {
let mut editor = editor();
@@ -1746,7 +1918,7 @@ fn loader_preserves_after_step_query_category_and_selection_then_compares() {
state.entries().nth(state.selected).unwrap().target,
RevisionTarget::FetchRemote("origin".into())
);
assert_eq!(state.entries().len(), 3);
assert_eq!(state.entries().len(), 4);
let remote_position = state
.entries()
.position(
+4
View File
@@ -1836,6 +1836,7 @@ fn hidden_tab_diff_results_restore_visible_insert_interaction_and_route_request_
let catalog = || {
Outcome::Catalog(Catalog {
kind: RepoKind::Git,
graph: Default::default(),
entries: [
RevisionTarget::Commit("base".into()),
RevisionTarget::Working,
@@ -2627,6 +2628,7 @@ fn diff_results_bind_the_owning_pane_without_disturbing_focused_document_or_mode
id,
Ok(Outcome::Catalog(Catalog {
kind: RepoKind::Git,
graph: Default::default(),
entries: vec![Revision {
target: RevisionTarget::Working,
label: "Working".into(),
@@ -3039,6 +3041,7 @@ fn working_diff_with_revisions(
id,
Ok(Outcome::Catalog(Catalog {
kind: RepoKind::Git,
graph: Default::default(),
entries: vec![
revision(RevisionTarget::Commit("base".into()), "base"),
revision(RevisionTarget::Working, "Working"),
@@ -3113,6 +3116,7 @@ fn historical_diff_with_revisions(
id,
Ok(Outcome::Catalog(Catalog {
kind: RepoKind::Git,
graph: Default::default(),
entries: vec![revision("base"), revision("head")],
notice: None,
})),
+74
View File
@@ -463,6 +463,80 @@ fn working_jj_conflicted_parent_uses_cli_projection_without_snapshotting() {
}
}
#[test]
fn jj_catalog_exposes_fixed_metadata_and_merge_parents_without_snapshotting() {
let temp = Temporary::new().unwrap();
let root = &temp.0;
fixture_command(root, "jj", &["git", "init", "--no-colocate", "."]);
fixture_command(root, "jj", &["describe", "-m", "Base"]);
let id = |root: &Path| {
fixture_command(
root,
"jj",
&[
"--ignore-working-copy",
"log",
"--no-graph",
"-r",
"@",
"-T",
"commit_id",
],
)
};
let base = id(root);
fixture_command(root, "jj", &["bookmark", "create", "base"]);
fixture_command(root, "jj", &["new", &base, "-m", "Left"]);
let left = id(root);
fixture_command(root, "jj", &["new", &base, "-m", "Right"]);
let right = id(root);
fixture_command(root, "jj", &["new", &left, &right, "-m", "Merge"]);
let current = id(root);
let operation = fixture_command(
root,
"jj",
&[
"--ignore-working-copy",
"op",
"log",
"--no-graph",
"-n",
"1",
"-T",
"id",
],
);
let mut repository = Repository::open(root, Arc::new(AtomicBool::new(false))).unwrap();
let catalog = repository.catalog().unwrap();
let merge = &catalog.graph[&current];
assert_eq!(merge.parents, [left, right]);
assert_eq!(merge.author, "Picker Author");
assert_eq!(merge.timestamp, "2001-01-01 00:00");
assert_eq!(merge.description, "Merge");
assert!(!merge.change_id.is_empty());
assert!(merge.current);
assert_eq!(catalog.graph[&base].bookmarks, "base");
assert!(!catalog.graph[&base].current);
assert!(catalog.graph[&"0".repeat(40)].immutable);
assert_eq!(
fixture_command(
root,
"jj",
&[
"--ignore-working-copy",
"op",
"log",
"--no-graph",
"-n",
"1",
"-T",
"id"
]
),
operation
);
}
#[test]
fn cancellation_kills_pipe_holding_children_and_drains_both_streams() {
let temp = Temporary::new().unwrap();
+2
View File
@@ -556,6 +556,7 @@ fn checkout_diff_polling_uses_injected_interval_independently_of_endpoint_follow
id,
Ok(DiffOutcome::Catalog(Catalog {
kind: RepoKind::Git,
graph: Default::default(),
notice: None,
entries: [
RevisionTarget::Working,
@@ -3725,6 +3726,7 @@ fn async_responses_edit_only_current_bound_working_after_and_refresh_projection(
id,
Ok(DiffOutcome::Catalog(Catalog {
kind: RepoKind::Git,
graph: Default::default(),
notice: None,
entries: vec![
revision(RevisionTarget::Commit("base".into()), "base"),
+129 -5
View File
@@ -1951,6 +1951,7 @@ fn diff_catalog() -> crate::diff::Catalog {
Catalog {
kind: RepoKind::Git,
entries,
graph: Default::default(),
notice: None,
}
}
@@ -3501,7 +3502,7 @@ fn diff_picker_keeps_working_pinned_while_windowing_searching_and_changing_stage
assert!(text.contains("Choose Before"));
assert!(text.contains("Tab categories"));
assert!(text.contains("Enter select"));
assert!(text.contains("Working checkout · pinned"));
assert!(text.contains("Checkout snapshot · pinned"));
assert!(text.contains("> commit-19"));
assert!(!text.contains("commit-00"));
for ch in "needle".chars() {
@@ -3511,13 +3512,14 @@ fn diff_picker_keeps_working_pinned_while_windowing_searching_and_changing_stage
render_diff(&mut terminal, &diff, &mut panels);
let text = screen_text(&terminal);
assert!(text.contains("> needle"));
assert!(text.contains("Working checkout · pinned"));
assert!(text.contains("Checkout snapshot · pinned"));
assert!(text.contains("> commit-19"));
diff.handle(Input::Enter);
render_diff(&mut terminal, &diff, &mut panels);
let text = screen_text(&terminal);
assert!(text.contains("Choose After"));
assert!(text.contains("Branch changes · Ctrl+b toggle · Before: commit-19"));
assert!(text.contains("Branch changes · Ctrl+b toggle"));
assert!(!text.contains("Before:"));
assert!(screen_text(&terminal).contains("Tab categories"));
assert!(screen_text(&terminal).contains("Enter select"));
diff.handle(Input::Ctrl('u'));
@@ -3525,8 +3527,8 @@ fn diff_picker_keeps_working_pinned_while_windowing_searching_and_changing_stage
render_diff(&mut terminal, &diff, &mut panels);
let text = screen_text(&terminal);
assert!(text.contains("feature-branch"));
assert!(!text.contains("ordinary change"));
assert!(text.contains("Working checkout · pinned"));
assert!(text.contains("commit-19 [Before]"));
assert!(text.contains("Checkout snapshot · pinned"));
let buffer = terminal.backend().buffer();
assert!(
buffer
@@ -3536,6 +3538,128 @@ fn diff_picker_keeps_working_pinned_while_windowing_searching_and_changing_stage
);
}
#[test]
fn checkout_picker_labels_follow_the_configured_stage() {
let mut diff = DiffState::new();
diff.follow_working_copy = true;
diff.take_request();
diff.apply_result(1, Ok(crate::diff::Outcome::Catalog(diff_catalog())));
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 16)).unwrap();
let mut panels = WheelPanels::default();
render_diff(&mut terminal, &diff, &mut panels);
assert!(
screen_text(&terminal).contains("Checkout snapshot · pinned · Live files at this checkout")
);
diff.handle(Input::Enter);
render_diff(&mut terminal, &diff, &mut panels);
assert!(screen_text(&terminal).contains("Follow checkout [Before] · pinned"));
assert!(screen_text(&terminal).contains("follows descendant checkouts"));
diff.follow_working_copy = false;
render_diff(&mut terminal, &diff, &mut panels);
assert!(screen_text(&terminal).contains("Checkout snapshot [Before] · pinned"));
assert!(!screen_text(&terminal).contains("Follow checkout"));
}
#[test]
fn jj_picker_renders_graph_before_badge_and_compacts_without_losing_controls() {
use crate::diff::{Category, RepoKind, Revision, RevisionGraph, RevisionTarget};
let mut catalog = diff_catalog();
catalog.kind = RepoKind::Jujutsu;
catalog.entries.truncate(1);
for (id, change, parent, current, immutable) in [
("aaaa1111", "change-a", Some("bbbb2222"), true, false),
("bbbb2222", "change-b", None, false, true),
] {
catalog.entries.push(Revision {
target: RevisionTarget::Commit(id.into()),
label: change.into(),
description: format!("{change} description"),
category: Category::Revisions,
});
catalog.graph.insert(
id.into(),
RevisionGraph {
change_id: change.into(),
description: format!("{change} description"),
parents: parent.into_iter().map(str::to_owned).collect(),
author: "alice".into(),
timestamp: "2026-10-23".into(),
bookmarks: if current {
"main".into()
} else {
String::new()
},
current,
immutable,
},
);
}
for name in ["main", "topic"] {
catalog.entries.push(Revision {
target: RevisionTarget::Bookmark {
name: name.into(),
remote: None,
},
label: name.into(),
description: "change-a aaaa1111 change-a description".into(),
category: Category::Bookmarks,
});
}
let mut diff = DiffState::new();
diff.take_request();
diff.apply_result(1, Ok(crate::diff::Outcome::Catalog(catalog)));
diff.handle(Input::Down);
diff.handle(Input::Enter);
diff.handle(Input::Down);
diff.handle(Input::Down);
let mut panels = WheelPanels::default();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap();
render_diff(&mut terminal, &diff, &mut panels);
let text = screen_text(&terminal);
assert!(text.contains("Choose After"));
assert!(text.contains("@"));
assert!(text.contains("change-a [Before] alice 2026-10-23 [main] aaaa1111"));
assert!(text.contains("change-a description"));
assert!(!text.contains("Before:"));
let buffer = terminal.backend().buffer();
let selected_rows: Vec<_> = (0..buffer.area.height)
.filter(|&y| (0..buffer.area.width).any(|x| buffer[(x, y)].bg == Color::Blue))
.collect();
assert_eq!(selected_rows.len(), 2);
assert_eq!(selected_rows[1], selected_rows[0] + 1);
let before_row = (0..buffer.area.height)
.find(|&y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
.contains("[Before]")
})
.unwrap();
assert!((0..buffer.area.width).all(|x| buffer[(x, before_row)].bg != Color::Blue));
for (width, height) in [(44, 12), (20, 8)] {
let mut terminal =
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
render_diff(&mut terminal, &diff, &mut panels);
let text = screen_text(&terminal);
assert!(text.contains("Choose After"));
assert!(text.contains("[Before]"));
assert!(text.contains("> "));
let buffer = terminal.backend().buffer();
assert_eq!(
(0..height)
.filter(|&y| { (0..width).any(|x| buffer[(x, y)].bg == Color::Blue) })
.count(),
1
);
}
diff.handle(Input::Char('\t'));
render_diff(&mut terminal, &diff, &mut panels);
let text = screen_text(&terminal);
assert!(text.contains("[main] change-a"));
assert!(text.contains("[topic] change-a"));
assert_eq!(text.matches("change-a description").count(), 2);
}
#[test]
fn pull_request_picker_renders_all_rows_without_a_pinned_offset() {
use crate::diff::{Category, PullRequestPage, RepoKind, Revision, RevisionTarget};