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 details;
|
||||||
|
mod diff;
|
||||||
mod filters;
|
mod filters;
|
||||||
mod git;
|
mod git;
|
||||||
mod settings;
|
mod settings;
|
||||||
@@ -34,6 +35,17 @@ fn commit_details(dir: tauri::State<RepoDir>, hash: String) -> Result<details::C
|
|||||||
details::load(&dir.get(), &hash)
|
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]
|
#[tauri::command]
|
||||||
fn load_filters(repo: String) -> Result<serde_json::Value, String> {
|
fn load_filters(repo: String) -> Result<serde_json::Value, String> {
|
||||||
filters::load(&repo)
|
filters::load(&repo)
|
||||||
@@ -69,6 +81,7 @@ pub fn run() {
|
|||||||
load_repo,
|
load_repo,
|
||||||
open_repo,
|
open_repo,
|
||||||
commit_details,
|
commit_details,
|
||||||
|
file_diff,
|
||||||
load_filters,
|
load_filters,
|
||||||
save_filters,
|
save_filters,
|
||||||
load_settings,
|
load_settings,
|
||||||
|
|||||||
+117
-5
@@ -11,7 +11,9 @@ import { layout } from "./lib/layout";
|
|||||||
import { textWidth } from "./lib/measure";
|
import { textWidth } from "./lib/measure";
|
||||||
import { colorOf } from "./lib/palette";
|
import { colorOf } from "./lib/palette";
|
||||||
import { addRecent } from "./lib/recents";
|
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 GraphCell from "./components/GraphCell.vue";
|
||||||
import LabelStack from "./components/LabelStack.vue";
|
import LabelStack from "./components/LabelStack.vue";
|
||||||
import PreviewPanel from "./components/PreviewPanel.vue";
|
import PreviewPanel from "./components/PreviewPanel.vue";
|
||||||
@@ -35,15 +37,17 @@ const sidebar = ref<InstanceType<typeof Sidebar>>();
|
|||||||
const SIDEBAR_MIN = 160;
|
const SIDEBAR_MIN = 160;
|
||||||
const SIDEBAR_MAX = 700;
|
const SIDEBAR_MAX = 700;
|
||||||
const PREVIEW_MIN = 80;
|
const PREVIEW_MIN = 80;
|
||||||
|
const LAYOUTS: Layout[] = ["unified", "split", "inline"];
|
||||||
|
|
||||||
interface Settings {
|
interface Settings {
|
||||||
sidebarWidth: number;
|
sidebarWidth: number;
|
||||||
previewHeight: number;
|
previewHeight: number;
|
||||||
previewShown: boolean;
|
previewShown: boolean;
|
||||||
|
diffLayout: Layout;
|
||||||
/** Repository roots, most recently opened first. */
|
/** Repository roots, most recently opened first. */
|
||||||
recents: string[];
|
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;
|
let settingsLoaded = false;
|
||||||
|
|
||||||
async function loadSettings() {
|
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?.previewHeight === "number") settings.previewHeight = Math.max(PREVIEW_MIN, saved.previewHeight);
|
||||||
if (typeof saved?.previewShown === "boolean") settings.previewShown = saved.previewShown;
|
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)) {
|
if (Array.isArray(saved?.recents)) {
|
||||||
// A repository opened before the settings arrived is already at the front.
|
// 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);
|
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();
|
toggled.value = new Set();
|
||||||
selected.value = 0;
|
selected.value = 0;
|
||||||
detailsCache.clear();
|
detailsCache.clear();
|
||||||
|
diffCache.clear();
|
||||||
if (scroller.value) scroller.value.scrollTop = 0;
|
if (scroller.value) scroller.value.scrollTop = 0;
|
||||||
settings.recents = addRecent(settings.recents, data.root);
|
settings.recents = addRecent(settings.recents, data.root);
|
||||||
await getCurrentWindow().setTitle(`gites — ${data.root}`);
|
await getCurrentWindow().setTitle(`gites — ${data.root}`);
|
||||||
@@ -269,6 +275,67 @@ watch([selectedHash, () => settings.previewShown], ([hash, shown]) => {
|
|||||||
}, 120);
|
}, 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 selectedLabels = computed(() => (selectedHash.value ? labels.value.get(selectedHash.value) ?? [] : []));
|
||||||
const selectedColor = computed(() => colorOf(graph.value.rows[selected.value]?.color ?? 0));
|
const selectedColor = computed(() => colorOf(graph.value.rows[selected.value]?.color ?? 0));
|
||||||
|
|
||||||
@@ -330,7 +397,28 @@ function jumpTo(r: Ref) {
|
|||||||
|
|
||||||
function onKey(e: KeyboardEvent) {
|
function onKey(e: KeyboardEvent) {
|
||||||
if (menuOpen.value || e.target instanceof HTMLInputElement || e.ctrlKey || e.metaKey || e.altKey) return;
|
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),
|
ArrowDown: () => select(selected.value + 1),
|
||||||
j: () => select(selected.value + 1),
|
j: () => select(selected.value + 1),
|
||||||
ArrowUp: () => select(selected.value - 1),
|
ArrowUp: () => select(selected.value - 1),
|
||||||
@@ -351,6 +439,7 @@ function onKey(e: KeyboardEvent) {
|
|||||||
r: load,
|
r: load,
|
||||||
o: () => (menuOpen.value = true),
|
o: () => (menuOpen.value = true),
|
||||||
p: () => (settings.previewShown = !settings.previewShown),
|
p: () => (settings.previewShown = !settings.previewShown),
|
||||||
|
Enter: openFirstFile,
|
||||||
"/": () => sidebar.value?.focusPattern(),
|
"/": () => sidebar.value?.focusPattern(),
|
||||||
};
|
};
|
||||||
const action = actions[e.key];
|
const action = actions[e.key];
|
||||||
@@ -442,7 +531,18 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<div v-else class="center">
|
<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 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 class="spacer-rows" :style="{ height: rows.length * ROW_H + 'px' }">
|
||||||
<div
|
<div
|
||||||
@@ -499,17 +599,29 @@ onUnmounted(() => {
|
|||||||
:labels="selectedLabels"
|
:labels="selectedLabels"
|
||||||
:color="selectedColor"
|
:color="selectedColor"
|
||||||
:format-time="formatTime"
|
:format-time="formatTime"
|
||||||
|
:chosen="diffFile?.path ?? null"
|
||||||
@jump="jumpToHash"
|
@jump="jumpToHash"
|
||||||
@copy="copyText"
|
@copy="copyText"
|
||||||
|
@open="openDiff"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</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>↑</kbd><kbd>↓</kbd> / <kbd>j</kbd><kbd>k</kbd> move</span>
|
||||||
<span><kbd>g</kbd><kbd>G</kbd> top / bottom</span>
|
<span><kbd>g</kbd><kbd>G</kbd> top / bottom</span>
|
||||||
<span><kbd>space</kbd> fold run</span>
|
<span><kbd>space</kbd> fold run</span>
|
||||||
|
<span><kbd>Enter</kbd> diff</span>
|
||||||
<span><kbd>c</kbd> fold all</span>
|
<span><kbd>c</kbd> fold all</span>
|
||||||
<span><kbd>y</kbd> copy hash</span>
|
<span><kbd>y</kbd> copy hash</span>
|
||||||
<span><kbd>h</kbd> hide branch</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">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import { computed, nextTick, ref, watch } from "vue";
|
||||||
import type { CommitDetails, ChangedFile } from "../lib/types";
|
import type { CommitDetails, ChangedFile } from "../lib/types";
|
||||||
import type { Label } from "./LabelStack.vue";
|
import type { Label } from "./LabelStack.vue";
|
||||||
import RefIcon from "./RefIcon.vue";
|
import RefIcon from "./RefIcon.vue";
|
||||||
@@ -10,8 +10,17 @@ const props = defineProps<{
|
|||||||
labels: Label[];
|
labels: Label[];
|
||||||
color: string;
|
color: string;
|
||||||
formatTime: (t: number) => 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 subject = computed(() => props.details?.message.split("\n")[0] ?? "");
|
||||||
const body = computed(() => props.details?.message.split("\n").slice(1).join("\n").trim() ?? "");
|
const body = computed(() => props.details?.message.split("\n").slice(1).join("\n").trim() ?? "");
|
||||||
@@ -75,13 +84,20 @@ function split(path: string) {
|
|||||||
</template>
|
</template>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
<div class="files">
|
<div ref="fileList" class="files">
|
||||||
<div class="files-head">
|
<div class="files-head">
|
||||||
{{ details.files.length }} changed file{{ details.files.length === 1 ? "" : "s" }}
|
{{ details.files.length }} changed file{{ details.files.length === 1 ? "" : "s" }}
|
||||||
<span class="a">+{{ totals.a }}</span> <span class="d">−{{ totals.d }}</span>
|
<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>
|
<span v-if="details.parents.length > 1" class="dim">compared with the first parent</span>
|
||||||
</div>
|
</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="st" :class="f.status">{{ f.status }}</span>
|
||||||
<span class="path">
|
<span class="path">
|
||||||
<template v-if="f.old_path"><span class="dim">{{ f.old_path }} → </span></template>
|
<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 { 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; }
|
.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 { 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: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 { 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; }
|
.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; }
|
.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