improve: show whole-diff totals alongside file stats

This commit is contained in:
Valdemar Lorenzen
2026-10-20 12:29:06 +02:00
parent a5a246db77
commit 759947736e
15 changed files with 394 additions and 48 deletions
+14 -7
View File
@@ -117,9 +117,10 @@ impl ChromeLine {
Self {
left: vec!["tabs".into()],
center: Vec::new(),
right: vec!["shell".into(), "battery".into()],
right: vec!["diff".into(), "shell".into(), "battery".into()],
segments: BTreeMap::from([
("tabs".into(), default_segment("tabs")),
("diff".into(), default_segment("diff")),
("battery".into(), default_segment("battery")),
("shell".into(), default_segment("shell")),
]),
@@ -194,17 +195,20 @@ impl ChromeLine {
fn default_segment(name: &str) -> SegmentConfig {
let mut segment = SegmentConfig::default();
if matches!(name, "tabs" | "position" | "battery" | "shell") {
if matches!(name, "tabs" | "position" | "battery" | "shell" | "diff") {
segment.leading_edge = "░▓".into();
}
if matches!(name, "tabs" | "mode" | "shell") {
if matches!(name, "tabs" | "mode" | "shell" | "diff") {
segment.trailing_edge = "▓░".into();
}
if matches!(name, "tabs" | "mode" | "position" | "battery" | "shell") {
if matches!(
name,
"tabs" | "mode" | "position" | "battery" | "shell" | "diff"
) {
segment.padding = 1;
}
if name == "tabs" {
segment.title = "{index} {view}{name}{modified}".into();
segment.title = "{index} {view}{name}{changes}{modified}".into();
segment.first_leading_edge = Some(String::new());
}
segment
@@ -233,9 +237,12 @@ fn validate_title(title: &str) -> Result<(), String> {
}
let end = rest.find('}').ok_or("title contains an unmatched '{'")?;
let placeholder = &rest[1..end];
if !matches!(placeholder, "index" | "view" | "name" | "path" | "modified") {
if !matches!(
placeholder,
"index" | "view" | "name" | "path" | "changes" | "modified"
) {
return Err(format!(
"unknown title placeholder {{{placeholder}}}; expected index, view, name, path, or modified"
"unknown title placeholder {{{placeholder}}}; expected index, view, name, path, changes, or modified"
));
}
rest = &rest[end + 1..];
+14
View File
@@ -135,6 +135,8 @@ pub struct ChangedFile {
pub status: FileStatus,
/// False means text, EOF flags, hunks and notices have not been prepared.
pub loaded: bool,
/// Frozen (added, removed) counts for catalog entries whose text is unloaded.
pub stats: Option<(usize, usize)>,
pub before: Vec<String>,
pub after: Vec<String>,
pub before_eof: bool,
@@ -142,6 +144,17 @@ pub struct ChangedFile {
pub hunks: Vec<Hunk>,
pub notice: Option<String>,
}
impl ChangedFile {
pub(crate) fn line_counts(&self) -> Option<(usize, usize)> {
if self.loaded {
Some(self.hunks.iter().fold((0, 0), |(added, removed), hunk| {
(added + hunk.after_len, removed + hunk.before_len)
}))
} else {
self.stats
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BlameRevision {
pub store: PathBuf,
@@ -690,6 +703,7 @@ impl DiffState {
comparison.root = root.clone();
comparison.files.resize_with(paths.len(), || ChangedFile {
review_revisions: None,
stats: None,
path: PathBuf::new(),
old_path: None,
status: FileStatus::Modified,
+136 -4
View File
@@ -2817,7 +2817,7 @@ impl Repository {
}
fn build_comparison(
&self,
&mut self,
prepared: &mut PreparedComparison,
same: bool,
) -> Result<Comparison, String> {
@@ -2835,6 +2835,7 @@ impl Repository {
}
files.push(ChangedFile {
review_revisions: None,
stats: None,
path: path.clone(),
old_path: None,
status: if a.is_none() {
@@ -2887,11 +2888,18 @@ impl Repository {
Ok(self.comparison_catalog(prepared))
}
fn comparison_catalog(&self, prepared: &PreparedComparison) -> Comparison {
fn comparison_catalog(&mut self, prepared: &PreparedComparison) -> Comparison {
let stats = if prepared.files.is_empty() {
Ok(Vec::new())
} else {
self.ensure_private()
.and_then(|()| self.comparison_stats(prepared))
};
let files = prepared
.files
.iter()
.map(|(path, old_path, status)| ChangedFile {
.enumerate()
.map(|(index, (path, old_path, status))| ChangedFile {
review_revisions: Some([
review_file_revision(prepared.left.get(old_path.as_ref().unwrap_or(path))),
review_file_revision(prepared.right.get(path)),
@@ -2900,12 +2908,16 @@ impl Repository {
old_path: old_path.clone(),
status: *status,
loaded: false,
stats: stats.as_ref().ok().and_then(|stats| stats[index]),
before: vec![],
after: vec![],
before_eof: false,
after_eof: false,
hunks: vec![],
notice: None,
notice: stats
.as_ref()
.err()
.map(|error| format!("Line statistics unavailable: {error}")),
})
.collect();
let editable_paths = if prepared.editable_after {
@@ -2934,6 +2946,125 @@ impl Repository {
}
}
fn comparison_stats(
&self,
prepared: &PreparedComparison,
) -> Result<Vec<Option<(usize, usize)>>, String> {
// ponytail: unopened counts use Git's text/Myers rules; share the renderer's
// counter if exact binary-detection and pathological-diff parity is needed.
let store = &self.private.as_ref().unwrap().1;
let sides: Vec<_> = prepared
.files
.iter()
.map(|(path, old_path, _)| {
[
prepared.left.get(old_path.as_ref().unwrap_or(path)),
prepared.right.get(path),
]
})
.collect();
let objects: BTreeSet<_> = sides
.iter()
.flatten()
.flatten()
.filter(|file| file.projection.is_none())
.filter_map(|file| file.object.as_ref().map(|(_, id)| id.as_str()))
.collect();
let mut sizes = BTreeMap::new();
if !objects.is_empty() {
let mut input = Vec::new();
for id in objects {
writeln!(&mut input, "{id}").unwrap();
}
let mut command = git_command(store);
command.arg("cat-file").arg("--batch-check");
let output = text(checked(run(command, input, &self.cancelled)?)?)?;
for line in output.lines() {
let fields: Vec<_> = line.split(' ').collect();
if fields.len() != 3 || fields[1] != "blob" {
return Err("Invalid statistics blob metadata".into());
}
let size = fields[2]
.parse::<usize>()
.map_err(|_| "Invalid statistics blob size")?;
sizes.insert(fields[0].to_owned(), size);
}
}
let mut stats = vec![Some((0, 0)); sides.len()];
let mut trees = [Vec::new(), Vec::new()];
for (index, files) in sides.iter().enumerate() {
self.check_cancelled()?;
if files.iter().flatten().any(|file| {
file.projection.is_some()
|| file.object.as_ref().is_some_and(|(_, id)| {
sizes
.get(id)
.is_none_or(|size| *size > COMPARISON_FILE_LIMIT)
})
}) {
stats[index] = None;
continue;
}
for (side, file) in files.iter().enumerate() {
if let Some((_, id)) = file.and_then(|file| file.object.as_ref()) {
// Flat synthetic paths avoid attributes, quoting and rename detection.
// Normalize modes: the renderer compares link targets as ordinary text.
write!(&mut trees[side], "100644 blob {id}\t{index}\0").unwrap();
}
}
}
if trees.iter().all(Vec::is_empty) {
return Ok(stats);
}
let mut ids = Vec::new();
for tree in trees {
let mut command = git_command(store);
command.args(["mktree", "-z"]);
ids.push(text(checked(run(command, tree, &self.cancelled)?)?)?);
}
let mut command = git_command(store);
command
.args(["-c", "core.attributesFile=/dev/null"])
.args([
"diff-tree",
"--numstat",
"-z",
"--no-renames",
"--no-ext-diff",
"--no-textconv",
"--diff-algorithm=myers",
"--no-indent-heuristic",
])
.args(ids.iter().map(|id| id.trim()))
.env("GIT_ATTR_NOSYSTEM", "1")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null");
let output = checked(run(command, Vec::new(), &self.cancelled)?)?;
for record in output.split(|byte| *byte == 0).filter(|r| !r.is_empty()) {
let record = std::str::from_utf8(record).map_err(|_| "Invalid line statistics")?;
let fields: Vec<_> = record.split('\t').collect();
if fields.len() != 3 {
return Err("Invalid line statistics".into());
}
let index = fields[2]
.parse::<usize>()
.ok()
.filter(|index| *index < stats.len())
.ok_or("Invalid line statistics index")?;
stats[index] = Some(if fields[0] == "-" && fields[1] == "-" {
(0, 0)
} else {
(
fields[0].parse().map_err(|_| "Invalid added line count")?,
fields[1]
.parse()
.map_err(|_| "Invalid removed line count")?,
)
});
}
Ok(stats)
}
/// Prepare one catalog entry using its original comparison's frozen sources.
pub fn load_file(
&mut self,
@@ -3044,6 +3175,7 @@ impl Repository {
notices.dedup();
Ok(ChangedFile {
review_revisions: None,
stats: None,
path: path.to_path_buf(),
old_path: None,
status,
+22 -7
View File
@@ -1643,8 +1643,8 @@ fn chrome_title(
};
match field {
"index" => write!(title, "{index}").unwrap(),
"view" if diff.is_some() => {
title.push_str("Diff · ");
"view" if diff.is_some() => title.push_str("Diff · "),
"changes" => {
if let Some(diff) = diff
&& diff.phase == Phase::View
&& let Some(file) = diff
@@ -1652,12 +1652,9 @@ fn chrome_title(
.as_ref()
.and_then(|comparison| comparison.files.get(diff.file))
&& file.loaded
&& let Some((added, removed)) = file.line_counts()
{
let (added, removed) =
file.hunks.iter().fold((0, 0), |(added, removed), hunk| {
(added + hunk.after_len, removed + hunk.before_len)
});
write!(title, "+{added} -{removed} · ").unwrap();
write!(title, " +{added} -{removed}").unwrap();
}
}
"name" => title.push_str(&name),
@@ -1783,6 +1780,24 @@ fn chrome_items<'a>(
} else {
String::new()
}),
"diff" => {
let Some(comparison) = editor
.workspace_diff()
.filter(|diff| diff.phase == Phase::View)
.and_then(|diff| diff.comparison.as_ref())
else {
continue;
};
match comparison
.files
.iter()
.try_fold((0, 0), |(added, removed), file| {
file.line_counts().map(|(a, r)| (added + a, removed + r))
}) {
Some((added, removed)) => Cow::Owned(format!("Diff +{added} -{removed}")),
None => Cow::Borrowed("Diff …"),
}
}
"battery" if editor.label(name).is_empty() => Cow::Borrowed(editor.battery_label()),
"shell" if editor.label(name).is_empty() => Cow::Borrowed(editor.shell_label()),
_ => Cow::Borrowed(editor.label(name)),