Skip to content

Commit 91ac8e1

Browse files
committed
diff: add option to display complex color-words diffs without inlining
In this patch, I use the number of adds<->removes alternation as a threshold, which approximates the visual complexity of diff hunks. I don't think user can choose the threshold intuitively, but we need a config knob to try out some. I set `max-inline-alternation = 3` locally. 0 and 1 mean "disable inlining" and "inline adds-only/removes-only lines" respectively. I've added "diff.<format>" config namespace assuming "ui.diff" will be reorganized as "ui.diff-formatter" or something. jj-vcs#3327 Some other metrics I've tried: ``` // Per-line alternation. This also works well, but can't measure complexity of // changes across lines. fn count_max_diff_alternation_per_line(diff_lines: &[DiffLine]) -> usize { diff_lines .iter() .map(|line| { let sides = line.hunks.iter().map(|&(side, _)| side); sides .filter(|&side| side != DiffLineHunkSide::Both) .dedup() // omit e.g. left->both->left .count() }) .max() .unwrap_or(0) } // Per-line occupancy of changes. Large diffs don't always look complex. fn max_diff_token_ratio_per_line(diff_lines: &[DiffLine]) -> f32 { diff_lines .iter() .filter_map(|line| { let [both_len, left_len, right_len] = line.hunks.iter().fold([0, 0, 0], |mut acc, (side, data)| { let index = match side { DiffLineHunkSide::Both => 0, DiffLineHunkSide::Left => 1, DiffLineHunkSide::Right => 2, }; acc[index] += data.len(); acc }); // left/right-only change is readable (left_len != 0 && right_len != 0).then(|| { let diff_len = left_len + right_len; let total_len = both_len + left_len + right_len; (diff_len as f32) / (total_len as f32) }) }) .reduce(f32::max) .unwrap_or(0.0) } // Total occupancy of changes. Large diffs don't always look complex. fn total_change_ratio(diff_lines: &[DiffLine]) -> f32 { let (diff_len, total_len) = diff_lines .iter() .flat_map(|line| &line.hunks) .fold((0, 0), |(diff_len, total_len), (side, data)| { let l = data.len(); match side { DiffLineHunkSide::Both => (diff_len, total_len + l), DiffLineHunkSide::Left => (diff_len + l, total_len + l), DiffLineHunkSide::Right => (diff_len + l, total_len + l), } }); (diff_len as f32) / (total_len as f32) } ```
1 parent 6c20c75 commit 91ac8e1

File tree

7 files changed

+726
-7
lines changed

7 files changed

+726
-7
lines changed

CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
2626
`--color-words`, `--git`, `--stat`, `--summary`, `--types`, and external diff
2727
tools in file-by-file mode.
2828

29+
* Color-words diff has gained [an option to display complex changes as separate
30+
lines](docs/config.md#color-words-diff-options).
31+
2932
* A tilde (`~`) at the start of the path will now be expanded to the user's home
3033
directory when configuring a `signing.key` for SSH commit signing.
3134

cli/src/commit_templater.rs

+2
Original file line numberDiff line numberDiff line change
@@ -1345,8 +1345,10 @@ fn builtin_tree_diff_methods<'repo>() -> CommitTemplateBuildMethodFnMap<'repo, T
13451345
let path_converter = language.path_converter;
13461346
let template = (self_property, context_property)
13471347
.map(move |(diff, context)| {
1348+
// TODO: load defaults from UserSettings?
13481349
let options = diff_util::ColorWordsOptions {
13491350
context: context.unwrap_or(diff_util::DEFAULT_CONTEXT_LINES),
1351+
max_inline_alternation: None,
13501352
};
13511353
diff.into_formatted(move |formatter, store, tree_diff| {
13521354
diff_util::show_color_words_diff(

cli/src/config-schema.json

+16
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,22 @@
276276
]
277277
}
278278
},
279+
"diff": {
280+
"type": "object",
281+
"description": "Builtin diff formats settings",
282+
"properties": {
283+
"color-words": {
284+
"type": "object",
285+
"description": "Options for color-words diffs",
286+
"properties": {
287+
"max-inline-alternation": {
288+
"type": "integer",
289+
"description": "Maximum number of removed/added word alternation to inline"
290+
}
291+
}
292+
}
293+
}
294+
},
279295
"git": {
280296
"type": "object",
281297
"description": "Settings for git behavior (when using git backend)",

cli/src/config/misc.toml

+3
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ amend = ["squash"]
55
co = ["checkout"]
66
unamend = ["unsquash"]
77

8+
[diff.color-words]
9+
max-inline-alternation = -1
10+
811
[ui]
912
# TODO: delete ui.allow-filesets in jj 0.26+
1013
allow-filesets = true

cli/src/diff_util.rs

+93-7
Original file line numberDiff line numberDiff line change
@@ -407,15 +407,28 @@ fn collect_copied_sources<'a>(
407407
pub struct ColorWordsOptions {
408408
/// Number of context lines to show.
409409
pub context: usize,
410+
/// Maximum number of removed/added word alternation to inline.
411+
pub max_inline_alternation: Option<usize>,
410412
}
411413

412414
impl ColorWordsOptions {
413415
fn from_settings_and_args(
414-
_settings: &UserSettings,
416+
settings: &UserSettings,
415417
args: &DiffFormatArgs,
416418
) -> Result<Self, config::ConfigError> {
419+
let config = settings.config();
420+
let max_inline_alternation = {
421+
let key = "diff.color-words.max-inline-alternation";
422+
match config.get_int(key)? {
423+
-1 => None, // unlimited
424+
n => Some(usize::try_from(n).map_err(|err| {
425+
config::ConfigError::Message(format!("invalid {key}: {err}"))
426+
})?),
427+
}
428+
};
417429
Ok(ColorWordsOptions {
418430
context: args.context.unwrap_or(DEFAULT_CONTEXT_LINES),
431+
max_inline_alternation,
419432
})
420433
}
421434
}
@@ -467,13 +480,35 @@ fn show_color_words_diff_hunks(
467480
)?;
468481
}
469482
DiffHunk::Different(contents) => {
470-
let word_diff = Diff::by_word(&contents);
471-
let mut diff_line_iter =
472-
DiffLineIterator::with_line_number(word_diff.hunks(), line_number);
473-
for diff_line in diff_line_iter.by_ref() {
474-
show_color_words_diff_line(formatter, &diff_line)?;
483+
let word_diff_hunks = Diff::by_word(&contents).hunks().collect_vec();
484+
let can_inline = match options.max_inline_alternation {
485+
None => true, // unlimited
486+
Some(0) => false, // no need to count alternation
487+
Some(max_num) => {
488+
let groups = split_diff_hunks_by_matching_newline(&word_diff_hunks);
489+
groups.map(count_diff_alternation).max().unwrap_or(0) <= max_num
490+
}
491+
};
492+
if can_inline {
493+
let mut diff_line_iter =
494+
DiffLineIterator::with_line_number(word_diff_hunks.iter(), line_number);
495+
for diff_line in diff_line_iter.by_ref() {
496+
show_color_words_diff_line(formatter, &diff_line)?;
497+
}
498+
line_number = diff_line_iter.next_line_number();
499+
} else {
500+
let (left_lines, right_lines) = unzip_diff_hunks_to_lines(&word_diff_hunks);
501+
for tokens in &left_lines {
502+
show_color_words_line_number(formatter, Some(line_number.left), None)?;
503+
show_color_words_single_sided_line(formatter, tokens, "removed")?;
504+
line_number.left += 1;
505+
}
506+
for tokens in &right_lines {
507+
show_color_words_line_number(formatter, None, Some(line_number.right))?;
508+
show_color_words_single_sided_line(formatter, tokens, "added")?;
509+
line_number.right += 1;
510+
}
475511
}
476-
line_number = diff_line_iter.next_line_number();
477512
}
478513
}
479514
}
@@ -544,6 +579,7 @@ fn show_color_words_line_number(
544579
Ok(())
545580
}
546581

582+
/// Prints `diff_line` which may contain tokens originating from both sides.
547583
fn show_color_words_diff_line(
548584
formatter: &mut dyn Formatter,
549585
diff_line: &DiffLine,
@@ -578,6 +614,56 @@ fn show_color_words_diff_line(
578614
Ok(())
579615
}
580616

617+
/// Prints left/right-only line tokens with the given label.
618+
fn show_color_words_single_sided_line(
619+
formatter: &mut dyn Formatter,
620+
tokens: &[(DiffTokenType, &[u8])],
621+
label: &str,
622+
) -> io::Result<()> {
623+
formatter.with_label(label, |formatter| show_diff_line_tokens(formatter, tokens))?;
624+
let (_, data) = tokens.last().expect("diff line must not be empty");
625+
if !data.ends_with(b"\n") {
626+
writeln!(formatter)?;
627+
};
628+
Ok(())
629+
}
630+
631+
/// Counts number of diff-side alternation, ignoring matching hunks.
632+
///
633+
/// This function is meant to measure visual complexity of diff hunks. It's easy
634+
/// to read hunks containing some removed or added words, but is getting harder
635+
/// as more removes and adds interleaved.
636+
///
637+
/// For example,
638+
/// - `[matching]` -> 0
639+
/// - `[left]` -> 1
640+
/// - `[left, matching, left]` -> 1
641+
/// - `[matching, left, right, matching, right]` -> 2
642+
/// - `[left, right, matching, right, left]` -> 3
643+
fn count_diff_alternation(diff_hunks: &[DiffHunk]) -> usize {
644+
diff_hunks
645+
.iter()
646+
.filter_map(|hunk| match hunk {
647+
DiffHunk::Matching(_) => None,
648+
DiffHunk::Different(contents) => Some(contents),
649+
})
650+
// Map non-empty diff side to index (0: left, 1: right)
651+
.flat_map(|contents| contents.iter().positions(|content| !content.is_empty()))
652+
// Omit e.g. left->(matching->)*left
653+
.dedup()
654+
.count()
655+
}
656+
657+
/// Splits hunks into slices of contiguous changed lines.
658+
fn split_diff_hunks_by_matching_newline<'a, 'b>(
659+
diff_hunks: &'a [DiffHunk<'b>],
660+
) -> impl Iterator<Item = &'a [DiffHunk<'b>]> {
661+
diff_hunks.split_inclusive(|hunk| match hunk {
662+
DiffHunk::Matching(content) => content.contains(&b'\n'),
663+
DiffHunk::Different(_) => false,
664+
})
665+
}
666+
581667
struct FileContent {
582668
/// false if this file is likely text; true if it is likely binary.
583669
is_binary: bool,

0 commit comments

Comments
 (0)