feat: default diffs to branch changes

This commit is contained in:
Valdemar Lorenzen
2026-10-13 10:41:37 +02:00
parent fafec2a6b0
commit 6c254f9b0d
10 changed files with 562 additions and 33 deletions
+99 -5
View File
@@ -39,6 +39,20 @@ impl RepoKind {
}
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum ComparisonMode {
#[default]
BranchChanges,
Snapshots,
}
impl ComparisonMode {
pub fn label(self) -> &'static str {
match self {
Self::BranchChanges => "Branch changes",
Self::Snapshots => "Snapshots",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum RevisionTarget {
Working,
@@ -105,6 +119,7 @@ pub enum Operation {
Compare {
before: Revision,
after: Revision,
mode: ComparisonMode,
buffers: Box<[(PathBuf, String)]>,
},
LoadFile {
@@ -187,6 +202,7 @@ pub struct DiffState {
pub phase: Phase,
pub word_wrap: bool,
pub view_mode: ViewMode,
pub comparison_mode: ComparisonMode,
pub split_axis: crate::panes::SplitAxis,
pub before: Option<Revision>,
pub after: Option<Revision>,
@@ -231,6 +247,7 @@ impl DiffState {
phase: Phase::Before,
word_wrap: false,
view_mode: ViewMode::Split,
comparison_mode: ComparisonMode::default(),
split_axis: crate::panes::SplitAxis::Vertical,
before: None,
after: None,
@@ -548,6 +565,9 @@ impl DiffState {
}
return;
};
if revision.target == RevisionTarget::Working {
self.comparison_mode = ComparisonMode::Snapshots;
}
if let RevisionTarget::FetchRemote(remote) = &revision.target {
self.queue(Operation::FetchRemote(remote.clone()));
} else if let RevisionTarget::ListRemote(remote) = &revision.target {
@@ -559,13 +579,43 @@ impl DiffState {
self.query.clear();
self.refilter();
self.error = None;
} else if let Some(before) = &self.before {
self.after = Some(revision.clone());
self.queue(Operation::Compare {
} else {
self.after = Some(revision);
self.queue_comparison();
}
}
pub(crate) fn working_selected(&self) -> bool {
[&self.before, &self.after]
.into_iter()
.flatten()
.any(|revision| revision.target == RevisionTarget::Working)
}
pub(crate) fn toggle_comparison_mode(&mut self) -> Result<(), &'static str> {
if self.working_selected() {
return Err("Working checkout requires Snapshots");
}
self.comparison_mode = match self.comparison_mode {
ComparisonMode::BranchChanges => ComparisonMode::Snapshots,
ComparisonMode::Snapshots => ComparisonMode::BranchChanges,
};
self.queue_comparison();
Ok(())
}
fn queue_comparison(&mut self) {
if let (Some(before), Some(after)) = (&self.before, &self.after) {
let operation = Operation::Compare {
before: before.clone(),
after: revision,
after: after.clone(),
mode: self.comparison_mode,
buffers: Box::new([]),
});
};
self.phase = Phase::After;
self.comparison = None;
self.comparison_id = None;
self.queue(operation);
}
}
@@ -3299,4 +3349,48 @@ mod tests {
})
));
}
#[test]
fn mode_action_keeps_filter_input_and_rejects_superseded_comparisons() {
let mut editor = editor();
open(&mut editor);
editor
.bind("normal", "m", Some("core.diff.mode.toggle".into()))
.unwrap();
text(&mut editor, "m");
assert_eq!(editor.diff().unwrap().query, "m");
assert_eq!(
editor.diff().unwrap().comparison_mode,
ComparisonMode::BranchChanges
);
editor.handle(Input::Ctrl('u'));
editor.handle(Input::Down);
editor.handle(Input::Enter);
editor.handle(Input::Down);
editor.handle(Input::Enter);
let (old, _) = editor.take_diff_request().unwrap();
editor.execute("diff.mode.toggle");
assert!(!editor.apply_diff_result(old, Err("superseded".into())));
let (current, operation) = editor.take_diff_request().unwrap();
assert!(matches!(
operation,
Operation::Compare {
mode: ComparisonMode::Snapshots,
..
}
));
assert!(editor.apply_diff_result(current, Err("retryable".into())));
assert_eq!(editor.diff().unwrap().phase, Phase::After);
assert_eq!(editor.diff().unwrap().error.as_deref(), Some("retryable"));
editor.execute("diff.mode.toggle");
let (_, operation) = editor.take_diff_request().unwrap();
assert!(matches!(
operation,
Operation::Compare {
mode: ComparisonMode::BranchChanges,
..
}
));
assert!(editor.diff().unwrap().error.is_none());
}
}
+23 -1
View File
@@ -703,6 +703,12 @@ core_actions![
true
),
("core.diff", Action::Diff, &[Mode::Normal], true),
(
"core.diff.mode.toggle",
Action::DiffModeToggle,
&[Mode::Normal],
true,
),
(
"core.diff.view.toggle",
Action::DiffViewToggle,
@@ -1070,6 +1076,7 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
}
for (key, action) in [
(Input::Ctrl('o'), JumpBack),
(Input::Ctrl('b'), DiffModeToggle),
(Input::Char('\t'), JumpForward),
(Input::Alt('h'), PaneFocusLeft),
(Input::Alt('j'), PaneFocusDown),
@@ -3144,6 +3151,7 @@ impl Editor {
| Action::FilePicker
| Action::FileExplorer
| Action::DiffClose
| Action::DiffModeToggle
| Action::DiffViewToggle
| Action::DiffOrientationToggle
| Action::WordWrapToggle
@@ -5905,6 +5913,7 @@ impl Editor {
| Action::BufferForceDelete
| Action::Diff
| Action::DiffClose
| Action::DiffModeToggle
| Action::DiffViewToggle
| Action::DiffOrientationToggle
| Action::WordWrapToggle
@@ -6713,7 +6722,7 @@ impl Editor {
}
if matches!(
entry.action,
Action::DiffViewToggle | Action::DiffOrientationToggle
Action::DiffModeToggle | Action::DiffViewToggle | Action::DiffOrientationToggle
) {
let name = entry.ex.expect("diff display action has an Ex command");
if !args.is_empty() {
@@ -6730,6 +6739,19 @@ impl Editor {
self.active.view.command_line.clear();
self.active.view.mode = Mode::Normal;
}
if entry.action == Action::DiffModeToggle {
let Some(diff) = self.diff.as_mut() else {
self.set_message("No comparison is open");
return None;
};
if let Err(error) = diff.toggle_comparison_mode() {
self.set_message(error);
return None;
}
self.release_diff_buffers();
self.diff_return_buffer = Some(self.active.id);
return None;
}
let Some(diff) = self.diff.as_mut().filter(|diff| diff.phase == Phase::View) else {
self.set_message("No comparison is open");
return None;
+88 -12
View File
@@ -1,7 +1,7 @@
//! Read-only active-repository discovery and isolated revision comparison.
use crate::diff::{
Catalog, Category, ChangedFile, Comparison, FileStatus, RepoKind, Revision, RevisionTarget,
line_hunks,
Catalog, Category, ChangedFile, Comparison, ComparisonMode, FileStatus, RepoKind, Revision,
RevisionTarget, line_hunks,
};
use std::{
collections::{BTreeMap, BTreeSet},
@@ -664,6 +664,27 @@ impl Repository {
checked(run(cmd, vec![], &self.cancelled)?)?;
self.private = Some((root.clone(), root));
}
// Only the private store gains alternates; active refs and objects stay untouched.
let objects = self.git(
&self.store,
&[
"rev-parse",
"--path-format=absolute",
"--git-path",
"objects",
],
)?;
let mut alternate = vec![b'"'];
for &byte in objects.strip_suffix(b"\n").unwrap_or(&objects) {
if (b' '..=b'~').contains(&byte) && !matches!(byte, b'"' | b'\\') {
alternate.push(byte);
} else {
write!(&mut alternate, "\\{byte:03o}").unwrap();
}
}
alternate.extend_from_slice(b"\"\n");
let store = &self.private.as_ref().unwrap().1;
fs::write(store.join("objects/info/alternates"), alternate).map_err(|e| e.to_string())?;
Ok(())
}
fn load_remote(&mut self, name: &str, reference: Option<&str>) -> Result<(), String> {
@@ -681,10 +702,7 @@ impl Repository {
let (root, store) = self.private.as_ref().unwrap();
if self.kind == RepoKind::Jujutsu {
if !self.registered.contains(name) {
self.jj(
root,
&["git", "remote", "add", "--fetch-tags=all", "--", name, &url],
)?;
self.jj(root, &["git", "remote", "add", "--", name, &url])?;
self.registered.insert(name.into());
}
self.jj(
@@ -1279,6 +1297,40 @@ impl Repository {
self.commit_snapshot(&store, &id)
}
}
fn merge_base(&mut self, before: &str, after: &str) -> Result<String, String> {
const JJ_ROOT: &str = "0000000000000000000000000000000000000000";
if self.kind == RepoKind::Jujutsu && (before == JJ_ROOT || after == JJ_ROOT) {
return Ok(JJ_ROOT.into());
}
let store = self
.private
.as_ref()
.map_or(&self.store, |(_, store)| store);
let mut command = git_command(store);
command.args(["merge-base", "--all", before, after]);
let output = run(command, vec![], &self.cancelled)?;
if output.code == Some(1) && output.stdout.is_empty() {
// Every jj history has the synthetic root, absent from Git's graph.
return if self.kind == RepoKind::Jujutsu {
Ok(JJ_ROOT.into())
} else {
Err(
"Branch changes: no merge base; use Snapshots to compare unrelated histories"
.into(),
)
};
}
let bases = text(checked(output).map_err(|e| format!("Cannot resolve merge base: {e}"))?)?;
let mut bases = bases.lines();
let base = bases.next().ok_or("Branch changes: no merge base")?;
if bases.next().is_some() {
return Err(
"Branch changes: multiple merge bases; select Snapshots or different endpoints"
.into(),
);
}
Ok(base.into())
}
/// Freeze comparison sources and return path/status metadata only.
/// A new comparison replaces sources retained for the previous request ID.
pub fn compare(
@@ -1286,19 +1338,43 @@ impl Repository {
comparison_id: u64,
before: &Revision,
after: &Revision,
mode: ComparisonMode,
buffers: &[(PathBuf, String)],
) -> Result<Comparison, String> {
self.comparison = None;
let mut left = self.snapshot(&before.target)?;
let working_after = after.target == RevisionTarget::Working;
let mut right = if before.target == after.target && (!working_after || buffers.is_empty()) {
let resolved = if mode == ComparisonMode::BranchChanges
&& before.target != RevisionTarget::Working
&& after.target != RevisionTarget::Working
{
let (_, before_id) = self.resolve(&before.target)?;
let (_, after_id) = self.resolve(&after.target)?;
let base = self.merge_base(&before_id, &after_id)?;
let label = format!(
"merge base of {} and {} · {base}",
before.label, after.label
);
Some((
RevisionTarget::Commit(base),
RevisionTarget::Commit(after_id),
label,
))
} else {
None
};
let (before_target, after_target, before_label) = resolved.as_ref().map_or(
(&before.target, &after.target, before.label.as_str()),
|(before, after, label)| (before, after, label.as_str()),
);
let mut left = self.snapshot(before_target)?;
let working_after = *after_target == RevisionTarget::Working;
let same = before_target == after_target && (!working_after || buffers.is_empty());
let mut right = if same {
BTreeMap::new()
} else if working_after {
self.working_snapshot(buffers)?
} else {
self.snapshot(&after.target)?
self.snapshot(after_target)?
};
let same = before.target == after.target && (!working_after || buffers.is_empty());
// Frozen checkout/conflict bytes have no object identity. Compare them in a
// single batch, while immutable tree/tree comparisons need only object IDs.
let frozen = |file: &FileData| {
@@ -1389,7 +1465,7 @@ impl Repository {
.collect(),
});
Ok(Comparison {
before: before.label.clone(),
before: before_label.into(),
after: after.label.clone(),
files,
root: self.root.clone(),
+2 -1
View File
@@ -78,9 +78,10 @@ impl DiffWorker {
DiffOperation::Compare {
before,
after,
mode,
buffers,
} => repository
.compare(id, &before, &after, &buffers)
.compare(id, &before, &after, mode, &buffers)
.map(DiffOutcome::Comparison),
DiffOperation::LoadFile {
comparison_id,
+13 -6
View File
@@ -2343,7 +2343,7 @@ fn draw_diff(
} else {
"After"
};
let title = format!(" Diff · Choose {stage} ");
let title = format!(" Diff · Choose {stage} · {} ", diff.comparison_mode.label());
let content = diff_panel(frame, panel, &title, "", "", true);
let notice = diff.error.as_deref().or_else(|| {
if diff.loading {
@@ -2381,7 +2381,11 @@ fn draw_diff(
.as_ref()
.map_or("not selected", |revision| revision.label.as_str());
frame.render_widget(
Paragraph::new(format!("Choose {stage} · Before: {}", display_text(before))),
Paragraph::new(format!(
"{} · Ctrl+b toggle · Before: {}",
diff.comparison_mode.label(),
display_text(before)
)),
stage_area,
);
if let Some(catalog) = &diff.catalog {
@@ -2869,7 +2873,7 @@ fn diff_pane_panel(
return area;
}
let title = file.map_or_else(
|| " Diff ".into(),
|| format!(" Diff · {} ", diff.comparison_mode.label()),
|file| {
let label = if diff.view_mode == ViewMode::Unified {
if before {
@@ -2888,9 +2892,10 @@ fn diff_pane_panel(
&file.path
};
format!(
" {label} · {} · {} ",
" {label} · {} · {} · {} ",
diff_status(file.status),
display_text(&path.to_string_lossy())
display_text(&path.to_string_lossy()),
diff.comparison_mode.label()
)
},
);
@@ -5179,7 +5184,9 @@ mod tests {
assert!(text.contains("> commit-19"));
diff.handle(Input::Enter);
render_diff(&mut terminal, &diff, &mut panels);
assert!(screen_text(&terminal).contains("Choose After · Before: commit-19"));
let text = screen_text(&terminal);
assert!(text.contains("Choose After"));
assert!(text.contains("Branch changes · Ctrl+b toggle · Before: commit-19"));
assert!(screen_text(&terminal).contains("Tab categories"));
assert!(screen_text(&terminal).contains("Enter select"));
diff.handle(Input::Ctrl('u'));