Blame · Line-by-line history
diff.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 82c3a38 | 1 | export type DiffLine = { |
| 2 | type: "add" | "del" | "ctx" | "hunk"; | |
| 3 | content: string; | |
| 4 | oldLine: number | null; | |
| 5 | newLine: number | null; | |
| 6 | wsOnly?: boolean; | |
| 7 | }; | |
| 8 | ||
| 9 | export type ParsedFile = { | |
| 10 | path: string; | |
| 11 | additions: number; | |
| 12 | deletions: number; | |
| 13 | isBinary: boolean; | |
| 14 | lines: DiffLine[]; | |
| 15 | }; | |
| 16 | ||
| 17 | export type SplitRow = { left: DiffLine | null; right: DiffLine | null }; | |
| 18 | ||
| 19 | export function parseDiff(raw: string): ParsedFile[] { | |
| 20 | const files: ParsedFile[] = []; | |
| 21 | const diffRe = /^diff --git a\/(.+?) b\/.+$/; | |
| 22 | const hunkRe = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/; | |
| 23 | ||
| 24 | let current: ParsedFile | null = null; | |
| 25 | let oldLine = 0; | |
| 26 | let newLine = 0; | |
| 27 | ||
| 28 | for (const line of raw.replace(/\r\n/g, "\n").split("\n")) { | |
| 29 | if (line === "") continue; // skip trailing split artifacts | |
| 30 | ||
| 31 | const dm = line.match(diffRe); | |
| 32 | if (dm) { | |
| 33 | if (current) files.push(current); | |
| 34 | current = { path: dm[1], additions: 0, deletions: 0, isBinary: false, lines: [] }; | |
| 35 | oldLine = 0; | |
| 36 | newLine = 0; | |
| 37 | continue; | |
| 38 | } | |
| 39 | if (!current) continue; | |
| 40 | ||
| 41 | if ( | |
| 42 | line.startsWith("index ") || | |
| 43 | line.startsWith("--- ") || | |
| 44 | line.startsWith("+++ ") || | |
| 45 | line.startsWith("new file") || | |
| 46 | line.startsWith("deleted file") || | |
| 47 | line.startsWith("old mode") || | |
| 48 | line.startsWith("new mode") || | |
| 49 | line.startsWith("rename ") | |
| 50 | ) continue; | |
| 51 | ||
| 52 | if (line.startsWith("Binary files")) { | |
| 53 | current.isBinary = true; | |
| 54 | continue; | |
| 55 | } | |
| 56 | if (current.isBinary) continue; | |
| 57 | ||
| 58 | const hm = line.match(hunkRe); | |
| 59 | if (hm) { | |
| 60 | oldLine = parseInt(hm[1], 10); | |
| 61 | newLine = parseInt(hm[2], 10); | |
| 62 | current.lines.push({ type: "hunk", content: line, oldLine: null, newLine: null }); | |
| 63 | continue; | |
| 64 | } | |
| 65 | ||
| 66 | if (line.startsWith("+")) { | |
| 67 | current.additions++; | |
| 68 | current.lines.push({ type: "add", content: line.slice(1), oldLine: null, newLine: newLine++ }); | |
| 69 | } else if (line.startsWith("-")) { | |
| 70 | current.deletions++; | |
| 71 | current.lines.push({ type: "del", content: line.slice(1), oldLine: oldLine++, newLine: null }); | |
| 72 | } else { | |
| 73 | current.lines.push({ type: "ctx", content: line.length > 0 ? line.slice(1) : "", oldLine: oldLine++, newLine: newLine++ }); | |
| 74 | } | |
| 75 | } | |
| 76 | if (current) files.push(current); | |
| 77 | ||
| 78 | // Whitespace-only detection: scan adjacent del/add pairs | |
| 79 | for (const file of files) { | |
| 80 | let i = 0; | |
| 81 | while (i < file.lines.length) { | |
| 82 | if (file.lines[i].type !== "del") { i++; continue; } | |
| 83 | const delStart = i; | |
| 84 | while (i < file.lines.length && file.lines[i].type === "del") i++; | |
| 85 | const addStart = i; | |
| 86 | while (i < file.lines.length && file.lines[i].type === "add") i++; | |
| 87 | const pairCount = Math.min(i - addStart, addStart - delStart); | |
| 88 | for (let j = 0; j < pairCount; j++) { | |
| 89 | const del = file.lines[delStart + j]; | |
| 90 | const add = file.lines[addStart + j]; | |
| 91 | if (del.content !== add.content && del.content.trim() === add.content.trim()) { | |
| 92 | del.wsOnly = true; | |
| 93 | add.wsOnly = true; | |
| 94 | } | |
| 95 | } | |
| 96 | } | |
| 97 | } | |
| 98 | ||
| 99 | return files; | |
| 100 | } | |
| 101 | ||
| 102 | export function pairLines(lines: DiffLine[]): SplitRow[] { | |
| 103 | const rows: SplitRow[] = []; | |
| 104 | let i = 0; | |
| 105 | while (i < lines.length) { | |
| 106 | const line = lines[i]; | |
| 107 | if (line.type === "ctx" || line.type === "hunk") { | |
| 108 | rows.push({ left: line, right: line }); | |
| 109 | i++; | |
| 110 | } else if (line.type === "del") { | |
| 111 | const dels: DiffLine[] = []; | |
| 112 | const adds: DiffLine[] = []; | |
| 113 | while (i < lines.length && lines[i].type === "del") dels.push(lines[i++]); | |
| 114 | while (i < lines.length && lines[i].type === "add") adds.push(lines[i++]); | |
| 115 | const len = Math.max(dels.length, adds.length); | |
| 116 | for (let j = 0; j < len; j++) rows.push({ left: dels[j] ?? null, right: adds[j] ?? null }); | |
| 117 | } else { | |
| 118 | rows.push({ left: null, right: lines[i++] }); | |
| 119 | } | |
| 120 | } | |
| 121 | return rows; | |
| 122 | } |