// A file's change as the view draws it: numbered lines, unchanged stretches folded, and what changed within lines. import type { FileDiff, Sent } from "./types.ts"; /** One line of a change, numbered on the sides it is on. */ export type Line = { mark: " " | "-" | "+"; /** Without its mark; a carriage return stays, for the view to show. */ text: string; old: number | null; new: number | null; /** Where it is among the lines of each side the change holds, which are what is highlighted. */ at: { old: number | null; new: number | null }; /** `false` for the last line of a file that ends without one. */ newline: boolean; }; export type Piece = | { kind: "lines"; lines: Line[] } /** Unchanged lines, folded until they are opened. */ | { kind: "fold"; lines: Line[] } /** Lines the patch left out: how many there are, when that is known. */ | { kind: "gap"; count: number }; /** A row of the side-by-side view. */ export type Pair = { old: Line | null; new: Line | null }; /** Character ranges, `[start, end)`. */ export type Range = readonly [number, number]; /** Added and removed lines, excluding context and patch metadata. */ export function lineCounts(file: FileDiff) { let additions = 0; let removals = 0; for (const hunk of file.hunks) { for (const line of hunk.lines) { if (line[0] === "+") additions++; else if (line[0] === "-") removals++; } } return { additions, removals }; } /** Every line `file`'s hunks hold, in order, and each side's text, for highlighting. */ export function linesOf(file: FileDiff) { const sides = { old: [] as string[], new: [] as string[] }; const hunks = file.hunks.map((hunk) => { let old = hunk.old; let neu = hunk.new; const lines: Line[] = []; for (const raw of hunk.lines) { const mark = raw[0]; if (mark === "\\") { const last = lines.at(-1); if (last) last.newline = false; continue; } if (mark !== " " && mark !== "-" && mark !== "+") continue; const text = raw.slice(1); const onOld = mark !== "+"; const onNew = mark !== "-"; lines.push({ mark, text, old: onOld ? old++ : null, new: onNew ? neu++ : null, at: { old: onOld ? sides.old.push(text.replace(/\r$/, "")) - 1 : null, new: onNew ? sides.new.push(text.replace(/\r$/, "")) - 1 : null, }, newline: true, }); } return { hunk, lines, end: { old, new: neu } }; }); return { hunks, sides }; } /** * `file`'s lines with each stretch of more than a few unchanged lines folded, but for `context` lines next to each * change; and gaps where the patch leaves lines out. */ export function pieces(file: FileDiff, context = 3): Piece[] { const out: Piece[] = []; const shown = (lines: Line[]) => { const last = out.at(-1); if (lines.length === 0) return; if (last?.kind === "lines") last.lines.push(...lines); else out.push({ kind: "lines", lines }); }; let previous: { old: number; new: number } = { old: 1, new: 1 }; for (const { hunk, lines, end } of linesOf(file).hunks) { const missing = Math.max(hunk.old - previous.old, hunk.new - previous.new); if (missing > 0) out.push({ kind: "gap", count: missing }); previous = end; let i = 0; while (i < lines.length) { let j = i; while (j < lines.length && lines[j].mark === " ") j++; if (j === i) { while (j < lines.length && lines[j].mark !== " ") j++; shown(lines.slice(i, j)); i = j; continue; } const before = i === 0 ? 0 : context; const after = j === lines.length ? 0 : context; // folding away less than this saves nothing if (j - i - before - after < 4) shown(lines.slice(i, j)); else { shown(lines.slice(i, i + before)); out.push({ kind: "fold", lines: lines.slice(i + before, j - after) }); shown(lines.slice(j - after, j)); } i = j; } } return out; } /** Rows side by side: unchanged lines beside themselves, and each run of removals beside the additions that follow. */ export function pairs(lines: readonly Line[]): Pair[] { const out: Pair[] = []; let i = 0; while (i < lines.length) { if (lines[i].mark === " ") { out.push({ old: lines[i], new: lines[i] }); i++; continue; } const removed: Line[] = []; const added: Line[] = []; while (i < lines.length && lines[i].mark !== " ") { (lines[i].mark === "-" ? removed : added).push(lines[i]); i++; } for (let k = 0; k < Math.max(removed.length, added.length); k++) { out.push({ old: removed[k] ?? null, new: added[k] ?? null }); } } return out; } const TOKEN = /[\p{L}\p{N}_]+|\s+|[^\p{L}\p{N}_\s]/gu; /** * What changed between two versions of a line, word by word, as ranges in each; `null` when they share too little for * that to say more than the whole line changing. */ export function wordChanges(a: string, b: string): { old: Range[]; new: Range[] } | null { const x = [...a.matchAll(TOKEN)]; const y = [...b.matchAll(TOKEN)]; if (x.length * y.length > 250_000) return null; // longest common subsequence of tokens, from the ends back const width = y.length + 1; const table = new Uint16Array((x.length + 1) * width); for (let i = x.length - 1; i >= 0; i--) { for (let j = y.length - 1; j >= 0; j--) { table[i * width + j] = x[i][0] === y[j][0] ? table[(i + 1) * width + j + 1] + 1 : Math.max(table[(i + 1) * width + j], table[i * width + j + 1]); } } const kept = { old: new Set(), new: new Set() }; let shared = 0; for (let i = 0, j = 0; i < x.length && j < y.length;) { if (x[i][0] === y[j][0]) { kept.old.add(i++); kept.new.add(j++); shared += x[i - 1][0].trim() ? x[i - 1][0].length : 0; } else if (table[(i + 1) * width + j] >= table[i * width + j + 1]) i++; else j++; } const visible = Math.max(a.replace(/\s/g, "").length, b.replace(/\s/g, "").length); if (visible > 0 && shared / visible < 0.4) return null; const ranges = (tokens: RegExpExecArray[], kept: Set) => { const out: [number, number][] = []; for (const [i, token] of tokens.entries()) { if (kept.has(i)) continue; const last = out.at(-1); if (last && last[1] === token.index) last[1] += token[0].length; else out.push([token.index, token.index + token[0].length]); } return out; }; return { old: ranges(x, kept.old), new: ranges(y, kept.new) }; } /** Whitespace at the end of a line, before any carriage return. */ export function trailing(text: string): Range | null { const match = text.match(/[ \t]+(?=\r?$)/); return match ? [match.index!, match.index! + match[0].length] : null; } /** A short, stable fingerprint of a text. */ function fingerprint(text: string) { let h1 = 0xdeadbeef; let h2 = 0x41c6ce57; for (let i = 0; i < text.length; i++) { const c = text.charCodeAt(i); h1 = Math.imul(h1 ^ c, 2654435761); h2 = Math.imul(h2 ^ c, 1597334677); } h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909); h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909); return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36); } /** A file's change by its path and a fingerprint of all of it, so marks of having viewed it lapse once it changes again. */ export const fileKey = (file: FileDiff) => `${file.path}\0${fingerprint(JSON.stringify(file))}`; /** `files` for a view that has the changes `known` names already, which so cost it only their keys. */ export function sending(files: readonly FileDiff[], known: readonly string[]): Sent[] { const has = new Set(known); return files.map((file) => { const key = fileKey(file); return { key, diff: has.has(key) ? null : file }; }); }