improve: show whole-diff totals alongside file stats
This commit is contained in:
+14
-7
@@ -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
@@ -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
@@ -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
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user