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
+248 -3
View File
@@ -96,10 +96,22 @@ pub struct Revision {
pub description: String,
pub category: Category,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct RevisionGraph {
pub change_id: String,
pub description: String,
pub parents: Vec<String>,
pub author: String,
pub timestamp: String,
pub bookmarks: String,
pub current: bool,
pub immutable: bool,
}
#[derive(Debug)]
pub struct Catalog {
pub kind: RepoKind,
pub entries: Vec<Revision>,
pub graph: BTreeMap<String, RevisionGraph>,
pub notice: Option<String>,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
@@ -761,6 +773,7 @@ pub struct DiffState {
pub word_wrap: bool,
pub view_mode: ViewMode,
pub comparison_mode: ComparisonMode,
pub follow_working_copy: bool,
pub split_axis: crate::panes::SplitAxis,
pub writable_side: Option<Focus>,
pub side_labels: [&'static str; 2],
@@ -808,6 +821,8 @@ pub struct DiffState {
pending: Option<Operation>,
request_kind: Option<RequestKind>,
visible: Vec<usize>,
picker_graph: BTreeMap<String, [String; 2]>,
bookmark_graph: BTreeMap<String, BTreeMap<Option<String>, String>>,
search_fields: Vec<[String; 3]>,
search_matches: Vec<([usize; 7], usize)>,
max_before: usize,
@@ -831,6 +846,7 @@ impl DiffState {
word_wrap: false,
view_mode: ViewMode::Split,
comparison_mode: ComparisonMode::default(),
follow_working_copy: false,
split_axis: crate::panes::SplitAxis::Vertical,
writable_side: Some(Focus::After),
side_labels: ["Before", "After"],
@@ -875,6 +891,8 @@ impl DiffState {
pending: Some(Operation::Discover),
request_kind: Some(RequestKind::Catalog),
visible: Vec::new(),
picker_graph: BTreeMap::new(),
bookmark_graph: BTreeMap::new(),
search_fields: Vec::new(),
search_matches: Vec::new(),
max_before: 0,
@@ -1178,6 +1196,7 @@ impl DiffState {
kind: page.kind,
entries: Vec::new(),
notice: Some(format!("Pull requests from {}", page.remote)),
graph: BTreeMap::new(),
});
for (revision, metadata) in page.entries {
let RevisionTarget::PullRequest { number, .. } = &revision.target else {
@@ -1436,6 +1455,20 @@ impl DiffState {
true
}
fn prepare_search(&mut self) {
self.bookmark_graph.clear();
if let Some(catalog) = &self.catalog {
for entry in &catalog.entries {
if let RevisionTarget::Bookmark { name, remote } = &entry.target
&& let Some(id) = entry.description.split_whitespace().nth(1)
&& catalog.graph.contains_key(id)
{
self.bookmark_graph
.entry(name.clone())
.or_default()
.insert(remote.clone(), id.into());
}
}
}
self.search_fields = self
.catalog
.as_ref()
@@ -1552,6 +1585,186 @@ impl DiffState {
}
}
pub fn graph_revision(&self, revision: &Revision) -> Option<(&str, &RevisionGraph)> {
let catalog = self.catalog.as_ref()?;
let id = match &revision.target {
RevisionTarget::Commit(id) => id.as_str(),
RevisionTarget::Bookmark { name, remote } => {
self.bookmark_graph.get(name)?.get(remote)?
}
_ => return None,
};
catalog
.graph
.get_key_value(id)
.map(|(id, graph)| (id.as_str(), graph))
}
pub fn picker_graph(&self) -> &BTreeMap<String, [String; 2]> {
&self.picker_graph
}
fn rebuild_picker_graph(&mut self) {
let mut result = BTreeMap::new();
let Some(catalog) = &self.catalog else {
self.picker_graph = result;
return;
};
let visible: Vec<_> = self
.entries()
.filter_map(|entry| self.graph_revision(entry))
.map(|(id, _)| id)
.collect();
let selected: BTreeSet<_> = visible.iter().copied().collect();
if selected.is_empty() {
self.picker_graph = result;
return;
}
let mut nearest: BTreeMap<&str, BTreeMap<&str, bool>> = BTreeMap::new();
for entry in catalog
.entries
.iter()
.rev()
.filter(|entry| entry.category == Category::Revisions)
{
let Some((id, graph)) = self.graph_revision(entry) else {
continue;
};
if nearest.contains_key(id) {
continue;
}
let mut ancestors = BTreeMap::new();
if selected.contains(id) {
ancestors.insert(id, false);
} else {
for parent in &graph.parents {
if let Some(parents) = nearest.get(parent.as_str()) {
ancestors.extend(parents.keys().map(|&parent| (parent, true)));
}
}
}
nearest.insert(id, ancestors);
}
let mut lanes: Vec<(&str, bool)> = Vec::new();
for id in visible {
if result.contains_key(id) {
continue;
}
let graph = &catalog.graph[id];
let mut parents = BTreeMap::new();
for parent in &graph.parents {
if let Some(ancestors) = nearest.get(parent.as_str()) {
for (&ancestor, &skipped) in ancestors {
parents
.entry(ancestor)
.and_modify(|old| *old |= skipped)
.or_insert(skipped);
}
}
}
let column = lanes
.iter()
.position(|&(lane, _)| lane == id)
.unwrap_or_else(|| {
lanes.push((id, false));
lanes.len() - 1
});
let mut next = lanes.clone();
next.remove(column);
for (offset, (&parent, &skipped)) in parents.iter().enumerate() {
if let Some(lane) = next.iter_mut().find(|(lane, _)| *lane == parent) {
lane.1 |= skipped;
} else {
next.insert((column + offset).min(next.len()), (parent, skipped));
}
}
let width = lanes.len().max(next.len()) * 2;
let mut first = vec![' '; width];
for (column, &(_, skipped)) in lanes.iter().enumerate() {
first[column * 2] = if skipped { '~' } else { '│' };
}
first[column * 2] = if graph.current {
'@'
} else if graph.immutable {
'◆'
} else {
'○'
};
// Low bits connect up/down/left/right; high bits distinguish joins from crossings.
let mut links = vec![0u8; width];
for (from, &(lane, _)) in lanes.iter().enumerate() {
for (to, &(target, _)) in next.iter().enumerate() {
if (lane == id && !parents.contains_key(target))
|| (lane != id && lane != target)
{
continue;
}
let (from, to) = (from * 2, to * 2);
if links[from] & 1 != 0 {
links[from] |= 16;
}
if links[to] & 2 != 0 {
links[to] |= 32;
}
if from == to {
links[from] |= 3;
} else {
links[from] |= 1 | if from < to { 8 } else { 4 };
links[to] |= 2 | if from < to { 4 } else { 8 };
for cell in &mut links[from.min(to) + 1..from.max(to)] {
*cell |= 12;
}
}
}
}
let mut second: Vec<_> = links
.into_iter()
.map(|link| match link & 15 {
3 => '│',
5 => '┘',
6 => '┐',
7 => '┤',
9 => '└',
10 => '┌',
11 => '├',
12 => '─',
13 => '┴',
14 => '┬',
15 => {
if link & 48 == 0 {
'╪'
} else {
'┼'
}
}
_ => ' ',
})
.collect();
for (column, &(_, skipped)) in next.iter().enumerate() {
if skipped {
second[column * 2] = '~';
}
}
if !graph.parents.is_empty() && parents.is_empty() {
second[column * 2] = '~';
}
if !parents.is_empty()
&& graph
.parents
.iter()
.any(|parent| nearest.get(parent.as_str()).is_none_or(BTreeMap::is_empty))
{
second.extend(['~', ' ']);
}
result.insert(
id.to_owned(),
[first.into_iter().collect(), second.into_iter().collect()],
);
lanes = next;
}
self.picker_graph = result;
}
fn refilter(&mut self) {
self.visible.clear();
self.search_matches.clear();
@@ -1619,7 +1832,7 @@ impl DiffState {
self.search_matches.push((score, index));
}
}
if self.pull_request_remote.is_none() {
if self.pull_request_remote.is_none() && catalog.kind == RepoKind::Git {
self.search_matches
.sort_unstable_by_key(|&(score, index)| (score, index));
}
@@ -1663,8 +1876,34 @@ impl DiffState {
b_number.cmp(&a_number)
});
}
if self.pull_request_remote.is_none() {
if let Some(before) = &self.before {
let same = |entry: &Revision| {
entry.target == before.target
|| self
.graph_revision(entry)
.zip(self.graph_revision(before))
.is_some_and(|(a, b)| a.0 == b.0)
};
if !self
.visible
.iter()
.any(|&index| same(&catalog.entries[index]))
&& let Some(index) = catalog
.entries
.iter()
.position(|entry| entry.target == before.target)
{
self.visible.push(index);
}
}
if catalog.kind == RepoKind::Jujutsu {
self.visible.sort_unstable();
}
}
}
self.selected = 0;
self.rebuild_picker_graph();
}
fn select_target(&mut self, target: &RevisionTarget) {
@@ -1838,7 +2077,7 @@ impl DiffState {
fn choose(&mut self) {
let revision = self.entries().nth(self.selected).cloned();
let Some(revision) = revision else {
let Some(mut revision) = revision else {
self.retry_pull_requests();
// Listing can be retried without closing the modal.
if self.catalog.is_none()
@@ -1861,6 +2100,12 @@ impl DiffState {
}
if revision.target == RevisionTarget::Working {
self.comparison_mode = ComparisonMode::Snapshots;
revision.label = if self.phase == Phase::After && self.follow_working_copy {
"Follow checkout"
} else {
"Checkout snapshot"
}
.into();
}
if let RevisionTarget::FetchRemote(remote) = &revision.target {
self.queue(Operation::FetchRemote(remote.clone()));
@@ -1891,7 +2136,7 @@ impl DiffState {
return Err("Select a pull request before changing comparison mode");
}
if self.working_selected() {
return Err("Working checkout requires Snapshots");
return Err("Checkout selections require Snapshots");
}
self.comparison_mode = match self.comparison_mode {
ComparisonMode::BranchChanges => ComparisonMode::Snapshots,
+3
View File
@@ -2282,6 +2282,7 @@ pub struct Editor {
diff_return_buffer: Option<usize>,
diff_context_options: (usize, usize),
diff_display_options: (ViewMode, SplitAxis),
pub(crate) diff_follow_working_copy: bool,
pub(crate) pull_request_review_requested_symbol: String,
pub(crate) pull_request_approved_symbol: String,
pub(crate) pull_request_preferences: crate::pull_requests::Preferences,
@@ -2429,6 +2430,7 @@ impl Editor {
diff_return_buffer: None,
diff_context_options: (10, 15),
diff_display_options: (ViewMode::Split, SplitAxis::Vertical),
diff_follow_working_copy: false,
pull_request_review_requested_symbol: "🙏".into(),
pull_request_approved_symbol: "👍".into(),
pull_request_preferences: Default::default(),
@@ -4764,6 +4766,7 @@ impl Editor {
diff.word_wrap = self.active.view.word_wrap;
diff.view_mode = self.diff_display_options.0;
diff.split_axis = self.diff_display_options.1;
diff.follow_working_copy = self.diff_follow_working_copy;
diff.set_context_options(self.diff_context_options.0, self.diff_context_options.1);
self.diff = Some(diff);
}
+2 -2
View File
@@ -85,7 +85,7 @@ Restart: :restart closes clean buffers; unsaved buffers remain open and block re
Tabs: :tab.new, :tab.previous, :tab.next, :tab.close, :tab.force_close
Tabs retain pane layouts and independent views; close protects exclusively displayed unsaved edits
Markdown links: :follow_link (primary caret; local files only)
Diff: :diff (Normal); choose Before then After; Working checkout After edits live unsaved buffers
Diff: :diff (Normal); floating jj log graph; choose Before then After; selected Before is marked inline
Before and historical/remote After stay read-only; Tab/Shift+Tab categories/Normal focus
All text panes use native motions, selections and copy; read-only panes reject edits/saves
Switching Before/After preserves the aligned cursor row and display column
@@ -93,7 +93,7 @@ Diff: :diff (Normal); choose Before then After; Working checkout After edits liv
Normal [/] explicitly expands context; Insert brackets remain literal text
Normal Space v cycles split/unified; Space s flips left/right vs above/below split
Defaults: config.diff_view_mode = \"split\"; config.diff_split_orientation = \"vertical\"
Working After follows descendant checkouts; config.diff_follow_working_copy = false disables it
Follow checkout After edits live buffers and follows descendants; config.diff_follow_working_copy = false disables following
Esc closes Before/After selectors; :diff.close exits an opened comparison explicitly
Remote refs/history load into temporary comparison repos without changing active refs/checkout
Changed files: normal picker/explorer bindings, Ctrl+w close; A/M/D/R status and rename paths
+108 -12
View File
@@ -1,7 +1,8 @@
//! Read-only active-repository discovery and isolated revision comparison.
use crate::diff::{
BlameRevision, Catalog, Category, ChangedFile, Comparison, ComparisonMode, FileStatus,
PullRequestMetadata, PullRequestPage, RepoKind, Revision, RevisionTarget, line_hunks,
PullRequestMetadata, PullRequestPage, RepoKind, Revision, RevisionGraph, RevisionTarget,
line_hunks,
};
use std::{
collections::{BTreeMap, BTreeSet},
@@ -1180,10 +1181,25 @@ impl Repository {
root: &Path,
store: &Path,
entries: &mut Vec<Revision>,
graph: &mut BTreeMap<String, RevisionGraph>,
notices: &mut Vec<String>,
) -> Result<(), String> {
if self.kind == RepoKind::Jujutsu {
let template = r#""[" ++ json(commit_id) ++ "," ++ json(change_id) ++ "," ++ json(description) ++ "," ++ json(local_bookmarks.map(|b| b.name())) ++ "," ++ json(remote_bookmarks.map(|b| b.name())) ++ "," ++ json(remote_bookmarks.map(|b| b.remote())) ++ "]\n""#;
#[derive(serde::Deserialize)]
struct JjRevision(
String,
String,
String,
Vec<String>,
Vec<String>,
Vec<String>,
Vec<String>,
String,
String,
bool,
bool,
);
let template = r#""[" ++ json(commit_id) ++ "," ++ json(change_id) ++ "," ++ json(description) ++ "," ++ json(local_bookmarks.map(|b| b.name())) ++ "," ++ json(remote_bookmarks.map(|b| b.name())) ++ "," ++ json(remote_bookmarks.map(|b| b.remote())) ++ "," ++ json(parents.map(|p| p.commit_id())) ++ "," ++ json(author.name()) ++ "," ++ json(author.timestamp().format("%Y-%m-%d %H:%M")) ++ "," ++ json(current_working_copy) ++ "," ++ json(immutable) ++ "]\n""#;
let revset = if root == self.root {
"all()"
} else {
@@ -1192,15 +1208,40 @@ impl Repository {
let output =
text(self.jj(root, &["log", "--no-graph", "-r", revset, "-T", template])?)?;
for line in output.lines() {
let (id, change, description, bookmarks, remote_names, remotes): (
String,
String,
String,
Vec<String>,
Vec<String>,
Vec<String>,
let JjRevision(
id,
change,
description,
bookmarks,
remote_names,
remotes,
parents,
author,
timestamp,
current,
immutable,
) = serde_json::from_str(line)
.map_err(|e| format!("Cannot decode Jujutsu revision: {e}"))?;
graph.entry(id.clone()).or_insert_with(|| RevisionGraph {
change_id: change.clone(),
description: description.trim_end_matches('\n').into(),
parents,
author,
timestamp,
bookmarks: bookmarks
.iter()
.cloned()
.chain(
remote_names
.iter()
.zip(&remotes)
.map(|(name, remote)| format!("{name}@{remote}")),
)
.collect::<Vec<_>>()
.join(" "),
current: current && root == self.root,
immutable,
});
let target = RevisionTarget::Commit(id.clone());
entries.push(Revision {
target: target.clone(),
@@ -1370,15 +1411,69 @@ impl Repository {
};
let mut entries = vec![Revision {
target: RevisionTarget::Working,
label: "Working checkout".into(),
label: "Checkout snapshot".into(),
description:
"On-disk tracked and eligible untracked files; unsaved buffers are excluded".into(),
category,
}];
let mut notices = vec![];
self.revisions(&self.root, &self.store, &mut entries, &mut notices)?;
let mut graph = BTreeMap::new();
self.revisions(
&self.root,
&self.store,
&mut entries,
&mut graph,
&mut notices,
)?;
if let Some((root, store)) = &self.private {
self.revisions(root, store, &mut entries, &mut notices)?;
self.revisions(root, store, &mut entries, &mut graph, &mut notices)?;
}
if self.kind == RepoKind::Jujutsu && self.private.is_some() {
let order: BTreeMap<_, _> = entries
.iter()
.enumerate()
.filter_map(|(index, entry)| match &entry.target {
RevisionTarget::Commit(id) => Some((id.as_str(), index)),
_ => None,
})
.collect();
let mut children: BTreeMap<_, usize> =
graph.keys().map(|id| (id.as_str(), 0)).collect();
for node in graph.values() {
for parent in &node.parents {
if let Some(count) = children.get_mut(parent.as_str()) {
*count += 1;
}
}
}
let mut ready: BTreeSet<_> = children
.iter()
.filter(|(_, count)| **count == 0)
.map(|(&id, _)| (order[id], id))
.collect();
let mut ranks = BTreeMap::new();
while let Some((_, id)) = ready.pop_first() {
ranks.insert(id, ranks.len() + 1);
for parent in &graph[id].parents {
if let Some(count) = children.get_mut(parent.as_str()) {
*count -= 1;
if *count == 0 {
ready.insert((order[parent.as_str()], parent.as_str()));
}
}
}
}
entries.sort_by_key(|entry| match &entry.target {
RevisionTarget::Working => 0,
RevisionTarget::Commit(id) => ranks.get(id.as_str()).copied().unwrap_or(usize::MAX),
RevisionTarget::Bookmark { .. } => entry
.description
.split_whitespace()
.nth(1)
.and_then(|id| ranks.get(id).copied())
.unwrap_or(usize::MAX),
_ => usize::MAX,
});
}
for remote in &self.remotes {
for &view in self.kind.categories() {
@@ -1439,6 +1534,7 @@ impl Repository {
Ok(Catalog {
kind: self.kind,
entries,
graph,
notice: if notices.is_empty() {
None
} else {
+1
View File
@@ -580,6 +580,7 @@ impl EditorSession {
editor.set_tab_spaces(config.tab_spaces, config.language_tab_spaces);
editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines);
editor.set_diff_display_options(config.diff_view_mode, config.diff_split_orientation);
editor.diff_follow_working_copy = config.diff_follow_working_copy;
editor.pull_request_review_requested_symbol = config.pull_request_review_requested_symbol;
editor.pull_request_approved_symbol = config.pull_request_approved_symbol;
editor.resolve_history_layout = config.resolve_history_layout;
+95 -12
View File
@@ -3588,6 +3588,8 @@ fn draw_diff(
}
} else if pull_requests {
" Pull requests ".into()
} else if panel.width < 50 {
format!(" Choose {stage} ")
} else {
format!(" Diff · Choose {stage} · {} ", diff.comparison_mode.label())
};
@@ -3700,10 +3702,6 @@ fn draw_diff(
Constraint::Length(u16::from(content.height >= 7)),
])
.areas(content);
let before = diff
.before
.as_ref()
.map_or("not selected", |revision| revision.label.as_str());
let stage_text = if pull_requests {
let filters = &diff.pull_request_preferences.filters;
let review = if filters.len() == 3 {
@@ -3735,11 +3733,7 @@ fn draw_diff(
},
)
} else {
format!(
"{} · Ctrl+b toggle · Before: {}",
diff.comparison_mode.label(),
display_text(before)
)
format!("{} · Ctrl+b toggle", diff.comparison_mode.label())
};
frame.render_widget(Paragraph::new(stage_text), stage_area);
if pull_requests || diff.catalog.is_some() {
@@ -3803,11 +3797,34 @@ fn draw_diff(
);
}
let mut entries = diff.entries();
let before_matches = |revision: &crate::diff::Revision| {
diff.before.as_ref().is_some_and(|before| {
match (diff.graph_revision(before), diff.graph_revision(revision)) {
(Some((before, _)), Some((candidate, _))) => before == candidate,
_ => before.target == revision.target,
}
})
};
if !pull_requests && let Some(working) = entries.next() {
let follow = diff.phase == Phase::After && diff.follow_working_copy;
let label = format!(
"{} {} · pinned",
"{} {}{} · pinned · {}",
if diff.selected == 0 { ">" } else { " " },
display_text(&working.label)
if follow {
"Follow checkout"
} else {
"Checkout snapshot"
},
if before_matches(working) {
" [Before]"
} else {
""
},
if follow {
"Live files; follows descendant checkouts"
} else {
"Live files at this checkout"
},
);
frame.render_widget(
Paragraph::new(visible_line(&label, 0, usize::from(pinned_area.width))).style(
@@ -3863,6 +3880,7 @@ fn draw_diff(
0
};
let decoration_width = symbol_width + usize::from(symbol_width > 0);
let graph = diff.picker_graph();
for (index, revision) in entries
.enumerate()
.skip(first)
@@ -3876,6 +3894,71 @@ fn draw_diff(
} else {
Style::default()
};
let before_badge = if before_matches(revision) {
" [Before]"
} else {
""
};
if let Some((id, metadata)) = diff.graph_revision(revision)
&& let Some(prefix) = graph.get(id)
{
let width = usize::from(results.width);
let change = metadata.change_id.as_str();
let change = change.get(..12).unwrap_or(change);
let bookmark = if revision.category == crate::diff::Category::Bookmarks {
format!("[{}] ", display_text(&revision.label))
} else {
String::new()
};
let label = format!(
"{} {}{bookmark}{}{} {} {} {} {}",
if index == diff.selected { ">" } else { " " },
prefix[0],
display_text(change),
before_badge,
display_text(&metadata.author),
display_text(&metadata.timestamp),
if metadata.bookmarks.is_empty() || !bookmark.is_empty() {
String::new()
} else {
format!("[{}]", display_text(&metadata.bookmarks))
},
&id[..id.len().min(12)],
);
if row_height == 2 {
lines.push(Line::styled(visible_line(&label, 0, width), style));
lines.push(Line::styled(
visible_line(
&format!(" {}{}", prefix[1], display_text(&metadata.description)),
0,
width,
),
style.fg(Color::DarkGray),
));
} else {
lines.push(Line::styled(
visible_line(
&format!(
"{} {}{}{}{bookmark}{} · {}",
if index == diff.selected { ">" } else { " " },
prefix[0],
if prefix[1].contains('~') { "~ " } else { "" },
if before_badge.is_empty() {
""
} else {
"[Before] "
},
display_text(change),
display_text(&metadata.description),
),
0,
width,
),
style,
));
}
continue;
}
let symbol = if pull_requests {
match diff.pull_request_review_state(revision) {
crate::diff::PullRequestReviewState::Requested => requested_symbol,
@@ -3886,7 +3969,7 @@ fn draw_diff(
""
};
let label = format!(
"{} {symbol}{:padding$}{}",
"{} {symbol}{:padding$}{}{before_badge}",
if index == diff.selected { ">" } else { " " },
"",
display_text(&revision.label),