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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user