From d75afe02498fb0c0ca784d3eff9fc67c09145893 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mirek=20Wr=C3=B3bel?= Date: Thu, 15 Oct 2026 05:13:29 +0200 Subject: [PATCH] Diff view for a file chosen in the commit preview: one column, side by side or inline, with changed words marked --- src-tauri/src/diff.rs | 334 ++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 13 ++ src/App.vue | 122 +++++++++++- src/components/DiffView.vue | 165 ++++++++++++++++ src/components/PreviewPanel.vue | 26 ++- src/lib/diff.test.ts | 139 +++++++++++++ src/lib/diff.ts | 306 +++++++++++++++++++++++++++++ 7 files changed, 1096 insertions(+), 9 deletions(-) create mode 100644 src-tauri/src/diff.rs create mode 100644 src/components/DiffView.vue create mode 100644 src/lib/diff.test.ts create mode 100644 src/lib/diff.ts diff --git a/src-tauri/src/diff.rs b/src-tauri/src/diff.rs new file mode 100644 index 0000000..0d23126 --- /dev/null +++ b/src-tauri/src/diff.rs @@ -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, +} + +#[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, + pub new_size: Option, + /// Empty for a binary file, a rename without changes, or a diff that is too large. + pub hunks: Vec, + /// The number of changed lines, when the diff is too large and was not sent. + pub too_large: Option, +} + +/// 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 { + // 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 { + 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); + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1cda772..fd985f2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod details; +mod diff; mod filters; mod git; mod settings; @@ -34,6 +35,17 @@ fn commit_details(dir: tauri::State, hash: String) -> Result, + hash: String, + path: String, + old_path: Option, + force: bool, +) -> Result { + diff::load(&dir.get(), &hash, &path, old_path.as_deref(), force) +} + #[tauri::command] fn load_filters(repo: String) -> Result { filters::load(&repo) @@ -69,6 +81,7 @@ pub fn run() { load_repo, open_repo, commit_details, + file_diff, load_filters, save_filters, load_settings, diff --git a/src/App.vue b/src/App.vue index 7a6f019..8955808 100644 --- a/src/App.vue +++ b/src/App.vue @@ -11,7 +11,9 @@ import { layout } from "./lib/layout"; import { textWidth } from "./lib/measure"; import { colorOf } from "./lib/palette"; import { addRecent } from "./lib/recents"; -import type { CommitDetails, FilterState, Ref, RefKind, RepoData } from "./lib/types"; +import type { FileDiff, Layout } from "./lib/diff"; +import type { ChangedFile, CommitDetails, FilterState, Ref, RefKind, RepoData } from "./lib/types"; +import DiffView from "./components/DiffView.vue"; import GraphCell from "./components/GraphCell.vue"; import LabelStack from "./components/LabelStack.vue"; import PreviewPanel from "./components/PreviewPanel.vue"; @@ -35,15 +37,17 @@ const sidebar = ref>(); const SIDEBAR_MIN = 160; const SIDEBAR_MAX = 700; const PREVIEW_MIN = 80; +const LAYOUTS: Layout[] = ["unified", "split", "inline"]; interface Settings { sidebarWidth: number; previewHeight: number; previewShown: boolean; + diffLayout: Layout; /** Repository roots, most recently opened first. */ recents: string[]; } -const settings = reactive({ sidebarWidth: 270, previewHeight: 220, previewShown: true, recents: [] }); +const settings = reactive({ sidebarWidth: 270, previewHeight: 220, previewShown: true, diffLayout: "unified", recents: [] }); let settingsLoaded = false; async function loadSettings() { @@ -53,6 +57,7 @@ async function loadSettings() { } if (typeof saved?.previewHeight === "number") settings.previewHeight = Math.max(PREVIEW_MIN, saved.previewHeight); if (typeof saved?.previewShown === "boolean") settings.previewShown = saved.previewShown; + if (saved?.diffLayout && LAYOUTS.includes(saved.diffLayout)) settings.diffLayout = saved.diffLayout; if (Array.isArray(saved?.recents)) { // A repository opened before the settings arrived is already at the front. settings.recents = saved.recents.reduceRight((list, p) => (list.includes(p) ? list : [...list, p]), settings.recents); @@ -140,6 +145,7 @@ async function applyRepo(data: RepoData) { toggled.value = new Set(); selected.value = 0; detailsCache.clear(); + diffCache.clear(); if (scroller.value) scroller.value.scrollTop = 0; settings.recents = addRecent(settings.recents, data.root); await getCurrentWindow().setTitle(`gites — ${data.root}`); @@ -269,6 +275,67 @@ watch([selectedHash, () => settings.previewShown], ([hash, shown]) => { }, 120); }); +// ---- diff of one file, shown in place of the commit graph --------------------- + +const diffCache = new Map(); +const diffFile = shallowRef(null); +const diffData = shallowRef(null); +const diffError = ref(null); +const diffView = ref>(); + +async function openDiff(file: ChangedFile, force = false) { + const hash = details.value?.hash; + if (!hash) return; + diffFile.value = file; + diffError.value = null; + const key = `${hash}\0${file.path}`; + const cached = diffCache.get(key); + if (cached && !force) { + diffData.value = cached; + return; + } + diffData.value = null; + try { + const d = await invoke("file_diff", { hash, path: file.path, oldPath: file.old_path, force }); + diffCache.set(key, d); + if (diffFile.value === file && details.value?.hash === hash) diffData.value = d; + } catch (e) { + if (diffFile.value === file) diffError.value = String(e); + } +} + +function closeDiff() { + diffFile.value = null; + nextTick(scrollToSelected); +} + +/** Opens the diff of the next (1) or previous (-1) file of the commit. */ +function stepFile(dir: 1 | -1) { + const files = details.value?.files ?? []; + const i = files.findIndex((f) => f.path === diffFile.value?.path); + const next = files[i + dir]; + if (next) openDiff(next); + else showToast(dir > 0 ? "this is the last file" : "this is the first file"); +} + +/** Opens the diff of the selected commit's first file. */ +function openFirstFile() { + if (!settings.previewShown) return showToast("show the preview panel first (p)"); + const d = details.value; + if (!d || d.hash !== selectedHash.value) return; + if (!d.files.length) return showToast("this commit changed no files"); + openDiff(d.files[0]); +} + +function cycleLayout() { + settings.diffLayout = LAYOUTS[(LAYOUTS.indexOf(settings.diffLayout) + 1) % LAYOUTS.length]; +} + +// Another commit (from a parent link, or a reload) closes the diff. +watch(selectedHash, () => { + if (diffFile.value) closeDiff(); +}); + const selectedLabels = computed(() => (selectedHash.value ? labels.value.get(selectedHash.value) ?? [] : [])); const selectedColor = computed(() => colorOf(graph.value.rows[selected.value]?.color ?? 0)); @@ -330,7 +397,28 @@ function jumpTo(r: Ref) { function onKey(e: KeyboardEvent) { if (menuOpen.value || e.target instanceof HTMLInputElement || e.ctrlKey || e.metaKey || e.altKey) return; - const actions: Record void> = { + const view = diffView.value; + const diffActions: Record void> = { + ArrowDown: () => view?.scrollLines(1), + j: () => view?.scrollLines(1), + ArrowUp: () => view?.scrollLines(-1), + k: () => view?.scrollLines(-1), + PageDown: () => view?.scrollPages(1), + PageUp: () => view?.scrollPages(-1), + Home: () => view?.toTop(), + g: () => view?.toTop(), + End: () => view?.toBottom(), + G: () => view?.toBottom(), + n: () => view?.jumpHunk(1), + N: () => view?.jumpHunk(-1), + "]": () => stepFile(1), + "[": () => stepFile(-1), + v: cycleLayout, + Escape: closeDiff, + y: copyHash, + p: () => (settings.previewShown = !settings.previewShown), + }; + const actions: Record void> = diffFile.value ? diffActions : { ArrowDown: () => select(selected.value + 1), j: () => select(selected.value + 1), ArrowUp: () => select(selected.value - 1), @@ -351,6 +439,7 @@ function onKey(e: KeyboardEvent) { r: load, o: () => (menuOpen.value = true), p: () => (settings.previewShown = !settings.previewShown), + Enter: openFirstFile, "/": () => sidebar.value?.focusPattern(), }; const action = actions[e.key]; @@ -442,7 +531,18 @@ onUnmounted(() => {
-
+ +
No commits to show. Check the filters on the left.
{ :labels="selectedLabels" :color="selectedColor" :format-time="formatTime" + :chosen="diffFile?.path ?? null" @jump="jumpToHash" @copy="copyText" + @open="openDiff" />
-