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,
|
||||
|
||||
+117
-5
@@ -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<InstanceType<typeof Sidebar>>();
|
||||
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<Settings>({ sidebarWidth: 270, previewHeight: 220, previewShown: true, recents: [] });
|
||||
const settings = reactive<Settings>({ 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<string, FileDiff>();
|
||||
const diffFile = shallowRef<ChangedFile | null>(null);
|
||||
const diffData = shallowRef<FileDiff | null>(null);
|
||||
const diffError = ref<string | null>(null);
|
||||
const diffView = ref<InstanceType<typeof DiffView>>();
|
||||
|
||||
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<FileDiff>("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<string, () => void> = {
|
||||
const view = diffView.value;
|
||||
const diffActions: Record<string, () => 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<string, () => 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(() => {
|
||||
|
||||
<div v-else class="center">
|
||||
|
||||
<div ref="scroller" class="list" @scroll="scrollTop = ($event.target as HTMLElement).scrollTop">
|
||||
<DiffView
|
||||
v-if="diffFile"
|
||||
ref="diffView"
|
||||
:file="diffFile"
|
||||
:diff="diffData"
|
||||
:error="diffError"
|
||||
:layout="settings.diffLayout"
|
||||
@layout="(l) => (settings.diffLayout = l)"
|
||||
@close="closeDiff"
|
||||
@force="openDiff(diffFile!, true)"
|
||||
/>
|
||||
<div v-show="!diffFile" ref="scroller" class="list" @scroll="scrollTop = ($event.target as HTMLElement).scrollTop">
|
||||
<div v-if="repo && rows.length === 0" class="empty">No commits to show. Check the filters on the left.</div>
|
||||
<div class="spacer-rows" :style="{ height: rows.length * ROW_H + 'px' }">
|
||||
<div
|
||||
@@ -499,17 +599,29 @@ onUnmounted(() => {
|
||||
:labels="selectedLabels"
|
||||
:color="selectedColor"
|
||||
:format-time="formatTime"
|
||||
:chosen="diffFile?.path ?? null"
|
||||
@jump="jumpToHash"
|
||||
@copy="copyText"
|
||||
@open="openDiff"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="keys">
|
||||
<footer v-if="diffFile" class="keys">
|
||||
<span><kbd>↑</kbd><kbd>↓</kbd> / <kbd>j</kbd><kbd>k</kbd> scroll</span>
|
||||
<span><kbd>PgUp</kbd><kbd>PgDn</kbd> page</span>
|
||||
<span><kbd>g</kbd><kbd>G</kbd> top / bottom</span>
|
||||
<span><kbd>n</kbd><kbd>N</kbd> next / previous change</span>
|
||||
<span><kbd>[</kbd><kbd>]</kbd> previous / next file</span>
|
||||
<span><kbd>v</kbd> switch layout</span>
|
||||
<span><kbd>Esc</kbd> back to graph</span>
|
||||
</footer>
|
||||
<footer v-else class="keys">
|
||||
<span><kbd>↑</kbd><kbd>↓</kbd> / <kbd>j</kbd><kbd>k</kbd> move</span>
|
||||
<span><kbd>g</kbd><kbd>G</kbd> top / bottom</span>
|
||||
<span><kbd>space</kbd> fold run</span>
|
||||
<span><kbd>Enter</kbd> diff</span>
|
||||
<span><kbd>c</kbd> fold all</span>
|
||||
<span><kbd>y</kbd> copy hash</span>
|
||||
<span><kbd>h</kbd> hide branch</span>
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { inlineRows, splitRows, unifiedRows, type FileDiff, type Layout, type Row, type SplitRow } from "../lib/diff";
|
||||
import type { ChangedFile } from "../lib/types";
|
||||
|
||||
const props = defineProps<{
|
||||
file: ChangedFile;
|
||||
diff: FileDiff | null;
|
||||
error: string | null;
|
||||
layout: Layout;
|
||||
}>();
|
||||
const emit = defineEmits<{ layout: [layout: Layout]; close: []; force: [] }>();
|
||||
|
||||
const LINE_H = 18;
|
||||
/** Rows are drawn in groups; the browser skips drawing groups that are out of view. */
|
||||
const GROUP = 200;
|
||||
const LAYOUTS: { id: Layout; name: string }[] = [
|
||||
{ id: "unified", name: "One column" },
|
||||
{ id: "split", name: "Side by side" },
|
||||
{ id: "inline", name: "Inline" },
|
||||
];
|
||||
|
||||
const groups = computed(() => {
|
||||
const hunks = props.diff?.hunks ?? [];
|
||||
const rows: (Row | SplitRow)[] =
|
||||
props.layout === "split" ? splitRows(hunks) : props.layout === "inline" ? inlineRows(hunks) : unifiedRows(hunks);
|
||||
const out: (Row | SplitRow)[][] = [];
|
||||
for (let i = 0; i < rows.length; i += GROUP) out.push(rows.slice(i, i + GROUP));
|
||||
return out;
|
||||
});
|
||||
const PIECE = { "=": "", "+": "pa", "-": "pd" } as const;
|
||||
const asRow = (r: Row | SplitRow) => r as Exclude<Row, { kind: "hunk" }>;
|
||||
const asPair = (r: Row | SplitRow) => r as Extract<SplitRow, { kind: "pair" }>;
|
||||
|
||||
const body = ref<HTMLElement>();
|
||||
watch(
|
||||
() => [props.file, props.diff],
|
||||
() => nextTick(() => body.value?.scrollTo({ top: 0 })),
|
||||
);
|
||||
|
||||
function size(n: number | null) {
|
||||
if (n === null) return "none";
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
scrollLines: (n: number) => body.value?.scrollBy({ top: n * LINE_H }),
|
||||
scrollPages: (n: number) => body.value?.scrollBy({ top: n * (body.value.clientHeight - 2 * LINE_H) }),
|
||||
toTop: () => body.value?.scrollTo({ top: 0 }),
|
||||
toBottom: () => body.value?.scrollTo({ top: body.value.scrollHeight }),
|
||||
/** Scrolls to the next (1) or previous (-1) changed block. */
|
||||
jumpHunk(dir: 1 | -1) {
|
||||
const el = body.value;
|
||||
if (!el) return;
|
||||
const tops = [...el.querySelectorAll<HTMLElement>("[data-hunk]")].map((h) => h.offsetTop);
|
||||
const target = dir > 0 ? tops.find((t) => t > el.scrollTop + 2) : tops.reverse().find((t) => t < el.scrollTop - 2);
|
||||
if (target !== undefined) el.scrollTo({ top: target });
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="diff">
|
||||
<header class="head">
|
||||
<span class="st" :class="file.status">{{ file.status }}</span>
|
||||
<span class="path" :title="file.old_path ? `${file.old_path} → ${file.path}` : file.path">
|
||||
<span v-if="file.old_path" class="dim">{{ file.old_path }} → </span>{{ file.path }}
|
||||
</span>
|
||||
<span v-if="file.added !== null" class="n"><span class="a">+{{ file.added }}</span> <span class="d">−{{ file.deleted }}</span></span>
|
||||
<span class="seg" title="switch layout (v)">
|
||||
<button v-for="l in LAYOUTS" :key="l.id" :class="{ on: layout === l.id }" @click="emit('layout', l.id)">{{ l.name }}</button>
|
||||
</span>
|
||||
<button class="close" title="back to the commit graph (Esc)" @click="emit('close')">Esc back to graph</button>
|
||||
</header>
|
||||
|
||||
<div ref="body" class="body">
|
||||
<div v-if="error" class="state error">{{ error }}</div>
|
||||
<div v-else-if="!diff" class="state">Loading…</div>
|
||||
<div v-else-if="diff.too_large !== null" class="state">
|
||||
Diff too large: {{ diff.too_large }} changed lines.
|
||||
<button @click="emit('force')">Show it anyway</button>
|
||||
</div>
|
||||
<div v-else-if="diff.binary" class="state">
|
||||
Binary file, not shown. Size before: {{ size(diff.old_size) }}, after: {{ size(diff.new_size) }}.
|
||||
</div>
|
||||
<div v-else-if="!diff.hunks.length" class="state">
|
||||
{{ file.old_path ? "Renamed, content not changed." : "Content not changed." }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-for="(group, g) in groups" :key="`${layout}-${g}`" class="group" :class="layout">
|
||||
<template v-for="(r, i) in group" :key="i">
|
||||
<div v-if="r.kind === 'hunk'" class="hunk" data-hunk>{{ r.text }}</div>
|
||||
<div v-else-if="r.kind === 'pair'" class="pair">
|
||||
<div v-for="(c, side) in [asPair(r).left, asPair(r).right]" :key="side" class="half" :class="c ? 'k' + (c.kind === ' ' ? 'c' : c.kind === '+' ? 'a' : 'd') : 'gap'">
|
||||
<template v-if="c">
|
||||
<span class="no">{{ c.no }}</span>
|
||||
<span class="mk">{{ c.kind === "+" ? "+" : c.kind === "-" ? "−" : "" }}</span>
|
||||
<span class="tx"><span v-for="(p, k) in c.pieces" :key="k" :class="PIECE[p.op]">{{ p.text }}</span></span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="line" :class="'k' + { ' ': 'c', '+': 'a', '-': 'd', '~': 'm' }[asRow(r).kind]">
|
||||
<template v-if="layout === 'inline'">
|
||||
<span class="no">{{ asRow(r).new ?? asRow(r).old }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="no">{{ asRow(r).old ?? "" }}</span>
|
||||
<span class="no">{{ asRow(r).new ?? "" }}</span>
|
||||
</template>
|
||||
<span class="mk">{{ { " ": "", "+": "+", "-": "−", "~": "±" }[asRow(r).kind] }}</span>
|
||||
<span class="tx"><span v-for="(p, k) in asRow(r).pieces" :key="k" :class="PIECE[p.op]">{{ p.text }}</span></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.diff { flex: 1; min-height: 0; display: flex; flex-direction: column; background: var(--bg); }
|
||||
.head {
|
||||
flex: none; display: flex; align-items: center; gap: 10px; padding: 5px 12px;
|
||||
border-bottom: 1px solid var(--border); background: var(--bg-side); font-size: 12px;
|
||||
}
|
||||
.st { width: 12px; flex: none; font: 700 11px ui-monospace, monospace; }
|
||||
.st.A { color: #69db7c; } .st.M { color: #ffa94d; } .st.D { color: #ff6b6b; } .st.R, .st.C { color: #4dabf7; } .st.T { color: #b197fc; }
|
||||
.path { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 12px ui-monospace, monospace; }
|
||||
.dim { color: var(--text-dim); }
|
||||
.n { flex: none; font: 11.5px ui-monospace, monospace; }
|
||||
.a { color: #69db7c; } .d { color: #ff6b6b; }
|
||||
.seg { display: inline-flex; flex: none; }
|
||||
.seg button { border-radius: 0; padding: 1px 9px; font-size: 11.5px; color: var(--text-dim); }
|
||||
.seg button + button { border-left: none; }
|
||||
.seg button:first-child { border-radius: 6px 0 0 6px; }
|
||||
.seg button:last-child { border-radius: 0 6px 6px 0; }
|
||||
.seg button.on { background: var(--bg-select); color: var(--text); }
|
||||
.close { flex: none; font-size: 11.5px; color: var(--text-dim); padding: 1px 8px; }
|
||||
|
||||
.body { flex: 1; min-height: 0; overflow: auto; position: relative; user-select: text; }
|
||||
.state { padding: 16px; color: var(--text-dim); display: flex; gap: 12px; align-items: center; }
|
||||
.state.error { color: #ff8787; }
|
||||
.group { content-visibility: auto; contain-intrinsic-size: auto 3600px; font: 12px/18px ui-monospace, monospace; }
|
||||
|
||||
.hunk { color: var(--accent); background: #1a1830; padding: 0 10px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.line, .half { display: grid; min-height: 18px; }
|
||||
.unified .line { grid-template-columns: 48px 48px 18px minmax(0, 1fr); }
|
||||
.inline .line, .half { grid-template-columns: 48px 18px minmax(0, 1fr); }
|
||||
.pair { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
||||
.pair .half:first-child { border-right: 1px solid var(--border); }
|
||||
.no { color: var(--text-dim); opacity: 0.7; text-align: right; padding-right: 8px; user-select: none; }
|
||||
.mk { user-select: none; text-align: center; }
|
||||
.tx { white-space: pre-wrap; overflow-wrap: anywhere; padding-right: 10px; }
|
||||
|
||||
.ka { background: #16301f; } .ka .mk { color: #69db7c; }
|
||||
.kd { background: #3a1a20; } .kd .mk { color: #ff6b6b; }
|
||||
.km { background: #1d2033; } .km .mk { color: #ffa94d; }
|
||||
.gap { background: repeating-linear-gradient(135deg, var(--bg-row-alt) 0 4px, var(--bg) 4px 8px); }
|
||||
.ka .pa { background: #2b6a3b; border-radius: 2px; }
|
||||
.kd .pd { background: #7a2a35; border-radius: 2px; }
|
||||
.km .pd { background: #5c2129; color: #ffb3b3; text-decoration: line-through; text-decoration-color: #ff8787; border-radius: 2px; }
|
||||
.km .pa { background: #24502f; color: #c3f5cc; border-radius: 2px; }
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import type { CommitDetails, ChangedFile } from "../lib/types";
|
||||
import type { Label } from "./LabelStack.vue";
|
||||
import RefIcon from "./RefIcon.vue";
|
||||
@@ -10,8 +10,17 @@ const props = defineProps<{
|
||||
labels: Label[];
|
||||
color: string;
|
||||
formatTime: (t: number) => string;
|
||||
/** The path of the file whose diff is open. */
|
||||
chosen: string | null;
|
||||
}>();
|
||||
const emit = defineEmits<{ jump: [hash: string]; copy: [text: string] }>();
|
||||
const emit = defineEmits<{ jump: [hash: string]; copy: [text: string]; open: [file: ChangedFile] }>();
|
||||
|
||||
// Keep the file whose diff is open in view, also when [ and ] move to another file.
|
||||
const fileList = ref<HTMLElement>();
|
||||
watch(
|
||||
() => props.chosen,
|
||||
() => nextTick(() => fileList.value?.querySelector(".file.on")?.scrollIntoView({ block: "nearest" })),
|
||||
);
|
||||
|
||||
const subject = computed(() => props.details?.message.split("\n")[0] ?? "");
|
||||
const body = computed(() => props.details?.message.split("\n").slice(1).join("\n").trim() ?? "");
|
||||
@@ -75,13 +84,20 @@ function split(path: string) {
|
||||
</template>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="files">
|
||||
<div ref="fileList" class="files">
|
||||
<div class="files-head">
|
||||
{{ details.files.length }} changed file{{ details.files.length === 1 ? "" : "s" }}
|
||||
<span class="a">+{{ totals.a }}</span> <span class="d">−{{ totals.d }}</span>
|
||||
<span v-if="details.parents.length > 1" class="dim">compared with the first parent</span>
|
||||
</div>
|
||||
<div v-for="f in details.files" :key="f.path" class="file" :title="f.old_path ? `${f.old_path} → ${f.path}` : f.path">
|
||||
<div
|
||||
v-for="f in details.files"
|
||||
:key="f.path"
|
||||
class="file"
|
||||
:class="{ on: chosen === f.path }"
|
||||
:title="(f.old_path ? `${f.old_path} → ${f.path}` : f.path) + ' (click to see the diff)'"
|
||||
@click="emit('open', f)"
|
||||
>
|
||||
<span class="st" :class="f.status">{{ f.status }}</span>
|
||||
<span class="path">
|
||||
<template v-if="f.old_path"><span class="dim">{{ f.old_path }} → </span></template>
|
||||
@@ -124,7 +140,9 @@ dd { margin: 0; min-width: 0; overflow-wrap: anywhere; display: flex; flex-wrap:
|
||||
.files-head { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 6px; display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.files-head .dim { text-transform: none; letter-spacing: 0; }
|
||||
.file { display: flex; align-items: center; gap: 8px; padding: 1px 4px; border-radius: 4px; font-size: 12px; }
|
||||
.file { cursor: pointer; }
|
||||
.file:hover { background: var(--bg-hover); }
|
||||
.file.on { background: var(--bg-select); box-shadow: inset 0 0 0 1px var(--accent); }
|
||||
.st { width: 12px; flex: none; font: 700 11px ui-monospace, monospace; }
|
||||
.st.A { color: #69db7c; } .st.M { color: #ffa94d; } .st.D { color: #ff6b6b; } .st.R, .st.C { color: #4dabf7; } .st.T { color: #b197fc; }
|
||||
.path { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 11.5px ui-monospace, monospace; }
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { inlineRows, splitRows, unifiedRows, wordDiff, type DiffLine, type Hunk, type Row, type SplitRow } from "./diff";
|
||||
|
||||
/** Builds a hunk from lines written as git prints them: " same", "-old", "+new". */
|
||||
function hunk(oldStart: number, newStart: number, ...lines: string[]): Hunk {
|
||||
return {
|
||||
header: `@@ -${oldStart} +${newStart} @@`,
|
||||
old_start: oldStart,
|
||||
new_start: newStart,
|
||||
lines: lines.map((l) => ({ kind: l[0] as DiffLine["kind"], text: l.slice(1) })),
|
||||
};
|
||||
}
|
||||
|
||||
/** Writes a row as text: removed parts in [-…-], added parts in {+…+}. */
|
||||
const show = (pieces: { op: string; text: string }[]) =>
|
||||
pieces.map((p) => (p.op === "-" ? `[-${p.text}-]` : p.op === "+" ? `{+${p.text}+}` : p.text)).join("");
|
||||
const rowText = (r: Row) => (r.kind === "hunk" ? r.text : `${r.kind} ${r.old ?? "."} ${r.new ?? "."} ${show(r.pieces)}`);
|
||||
const splitText = (r: SplitRow) =>
|
||||
r.kind === "hunk"
|
||||
? r.text
|
||||
: `${r.left ? `${r.left.kind}${r.left.no} ${show(r.left.pieces)}` : "~"} | ${r.right ? `${r.right.kind}${r.right.no} ${show(r.right.pieces)}` : "~"}`;
|
||||
|
||||
describe("wordDiff", () => {
|
||||
it("marks the words that changed", () => {
|
||||
expect(show(wordDiff("Pick a commit to see its message and the files", "Pick a commit to see its message, the files")!)).toBe(
|
||||
"Pick a commit to see its message[- and-]{+,+} the files",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps added text at the end of a line", () => {
|
||||
expect(show(wordDiff('<section class="preview">', '<section class="preview" @keydown="onKey">')!)).toBe(
|
||||
'<section class="preview"{+ @keydown="onKey"+}>',
|
||||
);
|
||||
});
|
||||
|
||||
it("gives up on lines with little in common", () => {
|
||||
expect(wordDiff("}", "function choose(f: ChangedFile) {")).toBeNull();
|
||||
});
|
||||
|
||||
it("joins changed words separated only by spaces into one change", () => {
|
||||
expect(show(wordDiff("// settings: the sidebar width", "// settings, saved in settings.json")!)).toBe(
|
||||
"// settings[-: the sidebar width-]{+, saved in settings.json+}",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats two equal lines as unchanged", () => {
|
||||
expect(wordDiff("same", "same")).toEqual([{ op: "=", text: "same" }]);
|
||||
});
|
||||
});
|
||||
|
||||
const change = hunk(40, 40, " keep", "-const open = ref(false);", "+const chosen = ref(null);", "+extra line", " end");
|
||||
|
||||
describe("unifiedRows", () => {
|
||||
it("numbers lines on both sides and marks changed words", () => {
|
||||
expect(unifiedRows([change]).map(rowText)).toEqual([
|
||||
"@@ -40 +40 @@",
|
||||
" 40 40 keep",
|
||||
"- 41 . const [-open-] = ref([-false-]);",
|
||||
"+ . 41 const {+chosen+} = ref({+null+});",
|
||||
"+ . 42 extra line",
|
||||
" 42 43 end",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not mark words in lines that were replaced by something unrelated", () => {
|
||||
const rows = unifiedRows([hunk(1, 1, "-}", "+function f() {")]);
|
||||
expect(rows.map(rowText).slice(1)).toEqual(["- 1 . }", "+ . 1 function f() {"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pairing removed and added lines", () => {
|
||||
const block = hunk(
|
||||
1,
|
||||
1,
|
||||
"-import type { FilterState, Ref } from types;",
|
||||
"+import { addRecent } from recents;",
|
||||
"+import type { CommitDetails, FilterState, Ref } from types;",
|
||||
);
|
||||
|
||||
it("pairs a removed line with the added line most like it", () => {
|
||||
expect(unifiedRows([block]).map(rowText).slice(1)).toEqual([
|
||||
"- 1 . import type { FilterState, Ref } from types;",
|
||||
"+ . 1 import { addRecent } from recents;",
|
||||
"+ . 2 import type { {+CommitDetails, +}FilterState, Ref } from types;",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the pair on one row side by side", () => {
|
||||
expect(splitRows([block]).map(splitText).slice(1)).toEqual([
|
||||
"~ | +1 import { addRecent } from recents;",
|
||||
"-1 import type { FilterState, Ref } from types; | +2 import type { {+CommitDetails, +}FilterState, Ref } from types;",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows the unpaired line before the pair inline", () => {
|
||||
expect(inlineRows([block]).map(rowText).slice(1)).toEqual([
|
||||
"+ . 1 import { addRecent } from recents;",
|
||||
"~ 1 2 import type { {+CommitDetails, +}FilterState, Ref } from types;",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitRows", () => {
|
||||
it("puts removed and added lines side by side, with a gap where one side has no line", () => {
|
||||
expect(splitRows([change]).map(splitText)).toEqual([
|
||||
"@@ -40 +40 @@",
|
||||
" 40 keep | 40 keep",
|
||||
"-41 const [-open-] = ref([-false-]); | +41 const {+chosen+} = ref({+null+});",
|
||||
"~ | +42 extra line",
|
||||
" 42 end | 43 end",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inlineRows", () => {
|
||||
it("merges a changed line into one row and keeps added lines whole", () => {
|
||||
expect(inlineRows([change]).map(rowText)).toEqual([
|
||||
"@@ -40 +40 @@",
|
||||
" 40 40 keep",
|
||||
"~ 41 41 const [-open-]{+chosen+} = ref([-false-]{+null+});",
|
||||
"+ . 42 extra line",
|
||||
" 42 43 end",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows unrelated lines as all removed, then all added", () => {
|
||||
const rows = inlineRows([hunk(1, 1, "-}", "-return x;", "+function f() {", "+ call();")]);
|
||||
expect(rows.map(rowText).slice(1)).toEqual(["- 1 . }", "- 2 . return x;", "+ . 1 function f() {", "+ . 2 call();"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("large blocks", () => {
|
||||
it("pairs lines by position when there are too many to compare every pair", () => {
|
||||
const lines = Array.from({ length: 60 }, (_, i) => `-value ${i} old`).concat(Array.from({ length: 60 }, (_, i) => `+value ${i} new`));
|
||||
const rows = inlineRows([hunk(1, 1, ...lines)]);
|
||||
expect(rows.length).toBe(61);
|
||||
expect(rowText(rows[60])).toBe("~ 60 60 value 59 [-old-]{+new+}");
|
||||
});
|
||||
});
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
// Turns the lines of one file's diff into rows for the three diff layouts.
|
||||
|
||||
export interface DiffLine {
|
||||
kind: " " | "+" | "-";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface Hunk {
|
||||
header: string;
|
||||
old_start: number;
|
||||
new_start: number;
|
||||
lines: DiffLine[];
|
||||
}
|
||||
|
||||
export interface FileDiff {
|
||||
binary: boolean;
|
||||
old_size: number | null;
|
||||
new_size: number | null;
|
||||
hunks: Hunk[];
|
||||
/** The number of changed lines, when the diff is too large and was not sent. */
|
||||
too_large: number | null;
|
||||
}
|
||||
|
||||
export type Layout = "unified" | "split" | "inline";
|
||||
|
||||
/** A part of a line: unchanged, added or removed. */
|
||||
export interface Piece {
|
||||
op: "=" | "+" | "-";
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** A row of the one-column and inline layouts. `~` is a changed line in the inline layout. */
|
||||
export type Row =
|
||||
| { kind: "hunk"; text: string }
|
||||
| { kind: " " | "+" | "-" | "~"; old: number | null; new: number | null; pieces: Piece[] };
|
||||
|
||||
export interface Cell {
|
||||
kind: " " | "+" | "-";
|
||||
no: number;
|
||||
pieces: Piece[];
|
||||
}
|
||||
|
||||
/** A row of the side-by-side layout; a side is null where the other side has a line with no partner. */
|
||||
export type SplitRow = { kind: "hunk"; text: string } | { kind: "pair"; left: Cell | null; right: Cell | null };
|
||||
|
||||
/** Words, runs of spaces, and single other characters. */
|
||||
const TOKEN = /\w+|\s+|[^\w\s]/gu;
|
||||
|
||||
/** Lines with less in common than this (share of their non-space characters) are not compared word by word. */
|
||||
const MIN_SIMILARITY = 0.3;
|
||||
/** Comparing words costs (words in one line) × (words in the other); longer pairs are not compared. */
|
||||
const MAX_WORK = 250_000;
|
||||
/** Pairing lines costs (removed lines) × (added lines) comparisons; larger blocks pair lines in order. */
|
||||
const MAX_PAIRING = 2_500;
|
||||
|
||||
const visible = (s: string) => s.replace(/\s/g, "").length;
|
||||
|
||||
/** Compares two lines word by word; `similarity` is the share of their non-space characters they have in common. */
|
||||
function compare(a: string, b: string): { pieces: Piece[]; similarity: number } | null {
|
||||
if (a === b) return { pieces: [{ op: "=", text: a }], similarity: 1 };
|
||||
const x = a.match(TOKEN) ?? [];
|
||||
const y = b.match(TOKEN) ?? [];
|
||||
if (x.length * y.length > MAX_WORK) return null;
|
||||
|
||||
// common[i][j]: how many tokens x[i..] and y[j..] have in common, in order.
|
||||
const common = Array.from({ length: x.length + 1 }, () => new Uint32Array(y.length + 1));
|
||||
for (let i = x.length - 1; i >= 0; i--) {
|
||||
for (let j = y.length - 1; j >= 0; j--) {
|
||||
common[i][j] = x[i] === y[j] ? common[i + 1][j + 1] + 1 : Math.max(common[i + 1][j], common[i][j + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Runs of unchanged tokens, each followed by the removed and added text after it.
|
||||
const runs: { same: string; del: string; add: string }[] = [{ same: "", del: "", add: "" }];
|
||||
let i = 0, j = 0, same = 0;
|
||||
while (i < x.length || j < y.length) {
|
||||
const last = runs[runs.length - 1];
|
||||
if (i < x.length && j < y.length && x[i] === y[j] && common[i][j] === common[i + 1][j + 1] + 1) {
|
||||
same += visible(x[i]);
|
||||
if (last.del || last.add) runs.push({ same: x[i], del: "", add: "" });
|
||||
else last.same += x[i];
|
||||
i++;
|
||||
j++;
|
||||
} else if (i < x.length && (j === y.length || common[i + 1][j] >= common[i][j + 1])) {
|
||||
// On a tie, removed text goes before added text.
|
||||
last.del += x[i++];
|
||||
} else {
|
||||
last.add += y[j++];
|
||||
}
|
||||
}
|
||||
const size = visible(a) + visible(b);
|
||||
if (size > 0 && (2 * same) / size < MIN_SIMILARITY) return null;
|
||||
|
||||
// Spaces alone between two changes join them, so "[-the-] [-sidebar-]" reads as one change.
|
||||
const pieces: Piece[] = [];
|
||||
let del = "", add = "";
|
||||
runs.forEach((r, k) => {
|
||||
const between = k > 0 && !r.same.trim() && (r.del || r.add) && (runs[k - 1].del || runs[k - 1].add);
|
||||
if (between) {
|
||||
del += r.same;
|
||||
add += r.same;
|
||||
} else {
|
||||
if (del) pieces.push({ op: "-", text: del });
|
||||
if (add) pieces.push({ op: "+", text: add });
|
||||
if (r.same) pieces.push({ op: "=", text: r.same });
|
||||
del = add = "";
|
||||
}
|
||||
del += r.del;
|
||||
add += r.add;
|
||||
});
|
||||
if (del) pieces.push({ op: "-", text: del });
|
||||
if (add) pieces.push({ op: "+", text: add });
|
||||
return { pieces, similarity: size ? (2 * same) / size : 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two lines word by word. Returns the parts of both lines in order, each marked as
|
||||
* unchanged, removed or added; or null when the lines have too little in common to pair.
|
||||
*/
|
||||
export function wordDiff(a: string, b: string): Piece[] | null {
|
||||
return compare(a, b)?.pieces ?? null;
|
||||
}
|
||||
|
||||
const oldSide = (pieces: Piece[]) => pieces.filter((p) => p.op !== "+");
|
||||
const newSide = (pieces: Piece[]) => pieces.filter((p) => p.op !== "-");
|
||||
const plain = (text: string): Piece[] => [{ op: "=", text }];
|
||||
|
||||
type Numbered = { no: number; text: string };
|
||||
/** A removed line paired with an added line, or a line with no partner. */
|
||||
type Step = { del?: Numbered; add?: Numbered; pieces?: Piece[] };
|
||||
/** A hunk split into unchanged lines and blocks of removed lines followed by added lines. */
|
||||
type Part =
|
||||
| { kind: " "; old: number; new: number; text: string }
|
||||
| { kind: "block"; dels: Numbered[]; adds: Numbered[]; steps: Step[] };
|
||||
|
||||
/**
|
||||
* Pairs removed lines with added lines, keeping both in order and choosing the pairs most
|
||||
* alike. Lines without a partner come before the next pair: removed ones first.
|
||||
*/
|
||||
function pairLines(dels: Numbered[], adds: Numbered[]): Step[] {
|
||||
const D = dels.length, A = adds.length;
|
||||
const steps: Step[] = [];
|
||||
let lone: Step[] = [], loneAdds: Step[] = [];
|
||||
const flush = () => {
|
||||
steps.push(...lone, ...loneAdds);
|
||||
lone = [];
|
||||
loneAdds = [];
|
||||
};
|
||||
|
||||
if (D * A > MAX_PAIRING) {
|
||||
// Too many lines to try every pair: only lines at the same place in the block can pair.
|
||||
for (let i = 0; i < Math.max(D, A); i++) {
|
||||
const pieces = i < D && i < A ? wordDiff(dels[i].text, adds[i].text) : null;
|
||||
if (pieces) {
|
||||
flush();
|
||||
steps.push({ del: dels[i], add: adds[i], pieces });
|
||||
continue;
|
||||
}
|
||||
if (i < D) lone.push({ del: dels[i] });
|
||||
if (i < A) loneAdds.push({ add: adds[i] });
|
||||
}
|
||||
flush();
|
||||
return steps;
|
||||
}
|
||||
|
||||
const match: (Piece[] | null)[][] = [];
|
||||
const sims: number[][] = [];
|
||||
for (let i = 0; i < D; i++) {
|
||||
match.push([]);
|
||||
sims.push([]);
|
||||
for (let j = 0; j < A; j++) {
|
||||
const c = compare(dels[i].text, adds[j].text);
|
||||
match[i].push(c?.pieces ?? null);
|
||||
sims[i].push(c?.similarity ?? 0);
|
||||
}
|
||||
}
|
||||
// score[i][j]: the best total likeness of pairs among dels[i..] and adds[j..].
|
||||
const score = Array.from({ length: D + 1 }, () => new Float64Array(A + 1));
|
||||
for (let i = D - 1; i >= 0; i--) {
|
||||
for (let j = A - 1; j >= 0; j--) {
|
||||
const paired = match[i][j] ? score[i + 1][j + 1] + sims[i][j] : 0;
|
||||
score[i][j] = Math.max(paired, score[i + 1][j], score[i][j + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
let i = 0, j = 0;
|
||||
while (i < D || j < A) {
|
||||
const pieces = i < D && j < A ? match[i][j] : null;
|
||||
if (pieces && score[i][j] === score[i + 1][j + 1] + sims[i][j]) {
|
||||
flush();
|
||||
steps.push({ del: dels[i++], add: adds[j++], pieces });
|
||||
} else if (i < D && (j === A || score[i + 1][j] >= score[i][j + 1])) {
|
||||
lone.push({ del: dels[i++] });
|
||||
} else {
|
||||
loneAdds.push({ add: adds[j++] });
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return steps;
|
||||
}
|
||||
|
||||
function parts(h: Hunk): Part[] {
|
||||
const out: Part[] = [];
|
||||
let o = h.old_start, n = h.new_start;
|
||||
let block: Extract<Part, { kind: "block" }> | null = null;
|
||||
for (const l of h.lines) {
|
||||
if (l.kind === " ") {
|
||||
block = null;
|
||||
out.push({ kind: " ", old: o++, new: n++, text: l.text });
|
||||
continue;
|
||||
}
|
||||
// A removed line after added ones starts a new block, so pairs keep git's order.
|
||||
if (!block || (l.kind === "-" && block.adds.length)) {
|
||||
block = { kind: "block", dels: [], adds: [], steps: [] };
|
||||
out.push(block);
|
||||
}
|
||||
if (l.kind === "-") block.dels.push({ no: o++, text: l.text });
|
||||
else block.adds.push({ no: n++, text: l.text });
|
||||
}
|
||||
for (const p of out) if (p.kind === "block") p.steps = pairLines(p.dels, p.adds);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function unifiedRows(hunks: Hunk[]): Row[] {
|
||||
const rows: Row[] = [];
|
||||
for (const h of hunks) {
|
||||
rows.push({ kind: "hunk", text: h.header });
|
||||
for (const p of parts(h)) {
|
||||
if (p.kind === " ") {
|
||||
rows.push({ kind: " ", old: p.old, new: p.new, pieces: plain(p.text) });
|
||||
continue;
|
||||
}
|
||||
// As git prints them: all removed lines, then all added lines.
|
||||
for (const s of p.steps) {
|
||||
if (s.del) rows.push({ kind: "-", old: s.del.no, new: null, pieces: s.pieces ? oldSide(s.pieces) : plain(s.del.text) });
|
||||
}
|
||||
for (const s of p.steps) {
|
||||
if (s.add) rows.push({ kind: "+", old: null, new: s.add.no, pieces: s.pieces ? newSide(s.pieces) : plain(s.add.text) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function splitRows(hunks: Hunk[]): SplitRow[] {
|
||||
const rows: SplitRow[] = [];
|
||||
for (const h of hunks) {
|
||||
rows.push({ kind: "hunk", text: h.header });
|
||||
for (const p of parts(h)) {
|
||||
if (p.kind === " ") {
|
||||
rows.push({
|
||||
kind: "pair",
|
||||
left: { kind: " ", no: p.old, pieces: plain(p.text) },
|
||||
right: { kind: " ", no: p.new, pieces: plain(p.text) },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Lines without a partner share rows with each other, up to the next pair.
|
||||
let dels: Numbered[] = [], adds: Numbered[] = [];
|
||||
const flush = () => {
|
||||
for (let i = 0; i < Math.max(dels.length, adds.length); i++) {
|
||||
const d = dels[i], a = adds[i];
|
||||
rows.push({
|
||||
kind: "pair",
|
||||
left: d ? { kind: "-", no: d.no, pieces: plain(d.text) } : null,
|
||||
right: a ? { kind: "+", no: a.no, pieces: plain(a.text) } : null,
|
||||
});
|
||||
}
|
||||
dels = [];
|
||||
adds = [];
|
||||
};
|
||||
for (const s of p.steps) {
|
||||
if (s.pieces && s.del && s.add) {
|
||||
flush();
|
||||
rows.push({
|
||||
kind: "pair",
|
||||
left: { kind: "-", no: s.del.no, pieces: oldSide(s.pieces) },
|
||||
right: { kind: "+", no: s.add.no, pieces: newSide(s.pieces) },
|
||||
});
|
||||
} else if (s.del) dels.push(s.del);
|
||||
else if (s.add) adds.push(s.add);
|
||||
}
|
||||
flush();
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function inlineRows(hunks: Hunk[]): Row[] {
|
||||
const rows: Row[] = [];
|
||||
for (const h of hunks) {
|
||||
rows.push({ kind: "hunk", text: h.header });
|
||||
for (const p of parts(h)) {
|
||||
if (p.kind === " ") {
|
||||
rows.push({ kind: " ", old: p.old, new: p.new, pieces: plain(p.text) });
|
||||
continue;
|
||||
}
|
||||
for (const s of p.steps) {
|
||||
if (s.pieces && s.del && s.add) rows.push({ kind: "~", old: s.del.no, new: s.add.no, pieces: s.pieces });
|
||||
else if (s.del) rows.push({ kind: "-", old: s.del.no, new: null, pieces: plain(s.del.text) });
|
||||
else if (s.add) rows.push({ kind: "+", old: null, new: s.add.no, pieces: plain(s.add.text) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
Reference in New Issue
Block a user