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,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