Diff view for a file chosen in the commit preview: one column, side by side or inline, with changed words marked

This commit is contained in:
2026-10-15 05:13:29 +02:00
parent 13f0fbcb39
commit d75afe0249
7 changed files with 1096 additions and 9 deletions
+334
View File
@@ -0,0 +1,334 @@
//! The changes to one file in one commit, for the diff view.
use crate::git::git;
use serde::Serialize;
use std::path::Path;
/// Above this many changed lines, or this many bytes of patch, the diff is only sent
/// when the user asks for it again.
const MAX_CHANGED_LINES: u32 = 20_000;
const MAX_PATCH_BYTES: usize = 5 * 1024 * 1024;
#[derive(Serialize, Debug, PartialEq)]
pub struct Line {
/// ' ' unchanged, '+' added, '-' removed.
pub kind: char,
pub text: String,
}
#[derive(Serialize, Debug, PartialEq)]
pub struct Hunk {
/// The whole `@@ … @@` line, with the function name git puts after it.
pub header: String,
pub old_start: u32,
pub new_start: u32,
pub lines: Vec<Line>,
}
#[derive(Serialize, Debug, PartialEq, Default)]
pub struct FileDiff {
pub binary: bool,
/// File sizes before and after the commit; only filled for binary files.
pub old_size: Option<u64>,
pub new_size: Option<u64>,
/// Empty for a binary file, a rename without changes, or a diff that is too large.
pub hunks: Vec<Hunk>,
/// The number of changed lines, when the diff is too large and was not sent.
pub too_large: Option<u32>,
}
/// Parses `git diff-tree -p` output for one file.
pub fn parse_patch(patch: &str) -> FileDiff {
let mut diff = FileDiff::default();
let mut in_hunk = false;
for raw in patch.lines() {
if let Some(rest) = raw.strip_prefix("@@ ") {
let mut starts = rest.split_whitespace().take(2).map(|r| {
r[1..].split(',').next().and_then(|n| n.parse().ok()).unwrap_or(0)
});
let (old_start, new_start) = (starts.next().unwrap_or(0), starts.next().unwrap_or(0));
diff.hunks.push(Hunk { header: raw.to_string(), old_start, new_start, lines: Vec::new() });
in_hunk = true;
} else if raw.starts_with("diff --git ") {
// A second file: git did not pair the old and new path as a rename.
in_hunk = false;
} else if !in_hunk {
if raw.starts_with("Binary files ") || raw == "GIT binary patch" {
diff.binary = true;
}
} else if let (Some(kind @ (' ' | '+' | '-')), Some(hunk)) = (raw.chars().next(), diff.hunks.last_mut()) {
hunk.lines.push(Line { kind, text: raw[1..].to_string() });
} else if raw.is_empty() {
// Some tools strip the space that starts an empty unchanged line.
if let Some(hunk) = diff.hunks.last_mut() {
hunk.lines.push(Line { kind: ' ', text: String::new() });
}
} else if !raw.starts_with('\\') {
// "\ No newline at end of file" is skipped; anything else ends the hunks.
in_hunk = false;
}
}
diff
}
pub fn load(dir: &Path, hash: &str, path: &str, old_path: Option<&str>, force: bool) -> Result<FileDiff, String> {
// The hash comes from the window; refuse anything git could read as an option.
if hash.is_empty() || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(format!("not a commit hash: {hash}"));
}
let parents = git(dir, &["rev-list", "--parents", "-n", "1", hash, "--"])?;
// A merge is compared with its first parent, as in the preview panel's file list.
let parent = parents.split_whitespace().nth(1).map(String::from);
let mut args = vec!["diff-tree", "-p", "-M", "-U3", "--no-color", "--no-ext-diff", "--no-textconv"];
match &parent {
Some(p) => args.extend([p.as_str(), hash]),
None => args.extend(["--root", "--no-commit-id", hash]),
}
args.extend(["--", path]);
if let Some(old) = old_path {
args.push(old);
}
let patch = git(dir, &args)?;
let mut diff = parse_patch(&patch);
if diff.binary {
let size = |spec: String| git(dir, &["cat-file", "-s", &spec]).ok().and_then(|s| s.trim().parse().ok());
diff.old_size = parent.as_ref().and_then(|p| size(format!("{p}:{}", old_path.unwrap_or(path))));
diff.new_size = size(format!("{hash}:{path}"));
}
let changed = diff.hunks.iter().flat_map(|h| &h.lines).filter(|l| l.kind != ' ').count() as u32;
if !force && (changed > MAX_CHANGED_LINES || patch.len() > MAX_PATCH_BYTES) {
diff.hunks.clear();
diff.too_large = Some(changed);
}
Ok(diff)
}
#[cfg(test)]
mod tests {
use super::*;
fn line(kind: char, text: &str) -> Line {
Line { kind, text: text.into() }
}
#[test]
fn parses_a_modified_file() {
let patch = "\
diff --git a/src/a.ts b/src/a.ts
index 1111111..2222222 100644
--- a/src/a.ts
+++ b/src/a.ts
@@ -1,3 +1,3 @@ export function f() {
one
-two
+TWO
three
@@ -10 +10,2 @@
-- a list item
+- a list item, longer
+++ not a header
";
let d = parse_patch(patch);
assert!(!d.binary);
assert_eq!(d.hunks.len(), 2);
assert_eq!(d.hunks[0].header, "@@ -1,3 +1,3 @@ export function f() {");
assert_eq!((d.hunks[0].old_start, d.hunks[0].new_start), (1, 1));
assert_eq!(d.hunks[0].lines, vec![line(' ', "one"), line('-', "two"), line('+', "TWO"), line(' ', "three")]);
assert_eq!((d.hunks[1].old_start, d.hunks[1].new_start), (10, 10));
assert_eq!(
d.hunks[1].lines,
vec![line('-', "- a list item"), line('+', "- a list item, longer"), line('+', "++ not a header")]
);
}
#[test]
fn parses_an_added_file_without_a_final_newline() {
let patch = "\
diff --git a/new.txt b/new.txt
new file mode 100644
index 0000000..3333333
--- /dev/null
+++ b/new.txt
@@ -0,0 +1,2 @@
+first\r
+second
\\ No newline at end of file
";
let d = parse_patch(patch);
assert_eq!((d.hunks[0].old_start, d.hunks[0].new_start), (0, 1));
assert_eq!(d.hunks[0].lines, vec![line('+', "first"), line('+', "second")]);
}
#[test]
fn parses_a_rename_without_changes() {
let patch = "\
diff --git a/old.rs b/new.rs
similarity index 100%
rename from old.rs
rename to new.rs
";
assert_eq!(parse_patch(patch), FileDiff::default());
}
#[test]
fn parses_a_binary_file() {
let patch = "\
diff --git a/logo.png b/logo.png
index 1111111..2222222 100644
Binary files a/logo.png and b/logo.png differ
";
let d = parse_patch(patch);
assert!(d.binary);
assert!(d.hunks.is_empty());
}
#[test]
fn parses_an_empty_patch() {
assert_eq!(parse_patch(""), FileDiff::default());
}
#[test]
fn refuses_a_hash_that_git_could_read_as_an_option() {
let err = load(Path::new("."), "--output=x", "a", None, false).unwrap_err();
assert!(err.contains("not a commit hash"), "{err}");
}
/// Runs git in a new repository with a few commits.
mod with_a_repository {
use super::super::*;
use std::path::PathBuf;
use std::process::Command;
struct Repo(PathBuf);
impl Drop for Repo {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
impl Repo {
fn new(name: &str) -> Repo {
let dir = std::env::temp_dir().join(format!("gites-diff-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let repo = Repo(dir);
repo.run(&["init", "-q", "-b", "main"]);
repo
}
fn run(&self, args: &[&str]) -> String {
let out = Command::new("git")
.arg("-C")
.arg(&self.0)
.args(["-c", "user.name=Test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false"])
.args(args)
.output()
.unwrap();
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn write(&self, path: &str, content: &[u8]) {
std::fs::write(self.0.join(path), content).unwrap();
}
fn commit(&self, message: &str) -> String {
self.run(&["add", "-A"]);
self.run(&["commit", "-q", "-m", message]);
self.run(&["rev-parse", "HEAD"])
}
}
fn texts(d: &FileDiff) -> Vec<String> {
d.hunks.iter().flat_map(|h| h.lines.iter().map(|l| format!("{}{}", l.kind, l.text))).collect()
}
#[test]
fn shows_the_first_commit_as_added_lines() {
let r = Repo::new("first");
r.write("a.txt", b"one\ntwo\n");
let first = r.commit("first");
let d = load(&r.0, &first, "a.txt", None, false).unwrap();
assert_eq!(texts(&d), vec!["+one", "+two"]);
}
#[test]
fn shows_a_change_and_a_rename_with_changes() {
let r = Repo::new("rename");
let body: String = (1..=20).map(|i| format!("line {i}\n")).collect();
r.write("a.txt", body.as_bytes());
r.commit("first");
r.write("a.txt", body.replace("line 5\n", "line five\n").as_bytes());
let second = r.commit("second");
assert_eq!(
texts(&load(&r.0, &second, "a.txt", None, false).unwrap()),
vec![" line 2", " line 3", " line 4", "-line 5", "+line five", " line 6", " line 7", " line 8"]
);
r.run(&["mv", "a.txt", "b.txt"]);
r.write("b.txt", body.replace("line 5\n", "line five\n").replace("line 20\n", "line twenty\n").as_bytes());
let third = r.commit("third");
let d = load(&r.0, &third, "b.txt", Some("a.txt"), false).unwrap();
assert_eq!(&texts(&d)[3..5], ["-line 20", "+line twenty"]);
}
#[test]
fn shows_nothing_for_a_rename_without_changes() {
let r = Repo::new("pure-rename");
r.write("a.txt", b"same\n");
r.commit("first");
r.run(&["mv", "a.txt", "b.txt"]);
let second = r.commit("second");
assert_eq!(load(&r.0, &second, "b.txt", Some("a.txt"), false).unwrap(), FileDiff::default());
}
#[test]
fn gives_sizes_for_a_binary_file() {
let r = Repo::new("binary");
r.write("x.bin", b"\0\x01\x02");
r.commit("first");
r.write("x.bin", b"\0\x01\x02\x03\x04");
let second = r.commit("second");
let d = load(&r.0, &second, "x.bin", None, false).unwrap();
assert!(d.binary);
assert_eq!((d.old_size, d.new_size), (Some(3), Some(5)));
std::fs::remove_file(r.0.join("x.bin")).unwrap();
let third = r.commit("third");
let d = load(&r.0, &third, "x.bin", None, false).unwrap();
assert_eq!((d.old_size, d.new_size), (Some(5), None));
}
#[test]
fn compares_a_merge_with_its_first_parent() {
let r = Repo::new("merge");
r.write("a.txt", b"base\n");
r.commit("base");
r.run(&["checkout", "-q", "-b", "side"]);
r.write("side.txt", b"from side\n");
r.commit("side");
r.run(&["checkout", "-q", "main"]);
r.write("a.txt", b"main\n");
r.commit("main");
r.run(&["merge", "-q", "--no-edit", "side"]);
let merge = r.run(&["rev-parse", "HEAD"]);
let d = load(&r.0, &merge, "side.txt", None, false).unwrap();
assert_eq!(texts(&d), vec!["+from side"]);
}
#[test]
fn holds_back_a_diff_that_is_too_large_until_forced() {
let r = Repo::new("large");
let body: String = (0..MAX_CHANGED_LINES + 1).map(|i| format!("{i}\n")).collect();
r.write("big.txt", body.as_bytes());
let first = r.commit("first");
let d = load(&r.0, &first, "big.txt", None, false).unwrap();
assert_eq!(d.too_large, Some(MAX_CHANGED_LINES + 1));
assert!(d.hunks.is_empty());
let d = load(&r.0, &first, "big.txt", None, true).unwrap();
assert_eq!(d.too_large, None);
assert_eq!(texts(&d).len(), (MAX_CHANGED_LINES + 1) as usize);
}
}
}
+13
View File
@@ -1,4 +1,5 @@
mod details;
mod diff;
mod filters;
mod git;
mod settings;
@@ -34,6 +35,17 @@ fn commit_details(dir: tauri::State<RepoDir>, hash: String) -> Result<details::C
details::load(&dir.get(), &hash)
}
#[tauri::command]
fn file_diff(
dir: tauri::State<RepoDir>,
hash: String,
path: String,
old_path: Option<String>,
force: bool,
) -> Result<diff::FileDiff, String> {
diff::load(&dir.get(), &hash, &path, old_path.as_deref(), force)
}
#[tauri::command]
fn load_filters(repo: String) -> Result<serde_json::Value, String> {
filters::load(&repo)
@@ -69,6 +81,7 @@ pub fn run() {
load_repo,
open_repo,
commit_details,
file_diff,
load_filters,
save_filters,
load_settings,