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
+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;
}