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

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