feat: default diffs to branch changes
This commit is contained in:
+99
-5
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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'));
|
||||
|
||||
Reference in New Issue
Block a user