Repository Public
1/**2 * Small diff toolkit for the timeline (no dependency):3 * parseUnifiedDiff — `git diff` / unified diff text → files → hunks → numbered lines4 * lineDiff — two strings → one hunk (LCS on lines), used for `edit_file` old/new strings5 * additionsOnly — new file content → one hunk of additions (`write_file`)6 * Everything returned is plain data; rendering escapes it as text.7 */8export type DiffLineType = 'ctx' | 'add' | 'del' | 'meta';9export interface DiffLine {10 type: DiffLineType;11 text: string;12 oldNo: number | null;13 newNo: number | null;14}15export interface DiffHunk {16 header: string | null;17 lines: DiffLine[];18}19export interface DiffFile {20 path: string | null;21 oldPath: string | null;22 note: string | null; // "new file", "deleted", "binary", "renamed"23 hunks: DiffHunk[];24 added: number;25 removed: number;26}2728const stripPrefix = (p: string): string | null => {29 const t = p.trim().split('\t')[0] ?? '';30 if (t === '/dev/null') return null;31 return t.replace(/^[ab]\//, '');32};3334export function looksLikeUnifiedDiff(text: string): boolean {35 return /^diff --git /m.test(text) || (/^--- /m.test(text) && /^\+\+\+ /m.test(text) && /^@@ /m.test(text));36}3738export function parseUnifiedDiff(text: string): DiffFile[] {39 const files: DiffFile[] = [];40 let file = null as DiffFile | null; // (cast: assigned inside start(), which control-flow analysis cannot see)41 let hunk = null as DiffHunk | null;42 let oldNo = 0;43 let newNo = 0;44 const start = (): DiffFile => {45 file = { path: null, oldPath: null, note: null, hunks: [], added: 0, removed: 0 };46 files.push(file);47 hunk = null;48 return file;49 };50 for (const raw of text.replace(/\r\n/g, '\n').split('\n')) {51 if (raw.startsWith('diff --git ')) {52 const f = start();53 const m = /^diff --git a\/(.+?) b\/(.+)$/.exec(raw);54 if (m) {55 f.oldPath = m[1] ?? null;56 f.path = m[2] ?? null;57 }58 continue;59 }60 if (!hunk || raw.startsWith('--- ') || raw.startsWith('+++ ')) {61 if (raw.startsWith('--- ') && (!hunk || !file)) {62 const f = file && file.hunks.length === 0 ? file : start();63 f.oldPath = stripPrefix(raw.slice(4)) ?? f.oldPath;64 if (stripPrefix(raw.slice(4)) === null) f.note = 'new file';65 continue;66 }67 if (raw.startsWith('+++ ') && file && !hunk) {68 const f: DiffFile = file;69 const p = stripPrefix(raw.slice(4));70 if (p === null) f.note = 'deleted';71 else f.path = p;72 continue;73 }74 }75 if (raw.startsWith('@@')) {76 const f = file ?? start();77 const m = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw);78 oldNo = m ? Number(m[1]) : 1;79 newNo = m ? Number(m[2]) : 1;80 hunk = { header: raw, lines: [] };81 f.hunks.push(hunk);82 continue;83 }84 if (!file) continue; // preamble before the first file85 const f: DiffFile = file;86 if (!hunk) {87 if (raw.startsWith('new file mode')) f.note = 'new file';88 else if (raw.startsWith('deleted file mode')) f.note = 'deleted';89 else if (raw.startsWith('rename from ')) f.note = 'renamed';90 else if (raw.startsWith('Binary files ')) f.note = 'binary';91 continue;92 }93 const h: DiffHunk = hunk;94 const c = raw[0];95 if (c === '+') {96 h.lines.push({ type: 'add', text: raw.slice(1), oldNo: null, newNo: newNo++ });97 f.added++;98 } else if (c === '-') {99 h.lines.push({ type: 'del', text: raw.slice(1), oldNo: oldNo++, newNo: null });100 f.removed++;101 } else if (c === '\\') {102 h.lines.push({ type: 'meta', text: raw.slice(2), oldNo: null, newNo: null });103 } else if (c === ' ' || raw === '') {104 if (raw === '' && h.lines.length === 0) continue;105 h.lines.push({ type: 'ctx', text: raw.slice(1), oldNo: oldNo++, newNo: newNo++ });106 }107 }108 // a trailing empty context line is an artefact of the final newline109 for (const f of files) {110 const last = f.hunks[f.hunks.length - 1];111 const tail = last?.lines[last.lines.length - 1];112 if (last && tail && tail.type === 'ctx' && tail.text === '') last.lines.pop();113 }114 return files.filter((f) => f.hunks.length > 0 || f.note !== null);115}116117const MAX_LCS_CELLS = 400_000;118/** Line diff of two strings as a single hunk. Falls back to "all removed, all added" for huge inputs. */119export function lineDiff(before: string, after: string): DiffHunk {120 const a = before === '' ? [] : before.replace(/\r\n/g, '\n').split('\n');121 const b = after === '' ? [] : after.replace(/\r\n/g, '\n').split('\n');122 const lines: DiffLine[] = [];123 // common prefix / suffix first: keeps the table small for the usual "few lines changed" edit124 let pre = 0;125 while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++;126 let suf = 0;127 while (suf < a.length - pre && suf < b.length - pre && a[a.length - 1 - suf] === b[b.length - 1 - suf]) suf++;128 const am = a.slice(pre, a.length - suf);129 const bm = b.slice(pre, b.length - suf);130 let oldNo = 1;131 let newNo = 1;132 const ctx = (t: string) => lines.push({ type: 'ctx', text: t, oldNo: oldNo++, newNo: newNo++ });133 const del = (t: string) => lines.push({ type: 'del', text: t, oldNo: oldNo++, newNo: null });134 const add = (t: string) => lines.push({ type: 'add', text: t, oldNo: null, newNo: newNo++ });135 for (let i = 0; i < pre; i++) ctx(a[i] ?? '');136 if (am.length * bm.length > MAX_LCS_CELLS) {137 am.forEach(del);138 bm.forEach(add);139 } else {140 const n = am.length;141 const m = bm.length;142 const w = m + 1;143 const t = new Uint32Array((n + 1) * w);144 for (let i = n - 1; i >= 0; i--) {145 for (let j = m - 1; j >= 0; j--) {146 t[i * w + j] = am[i] === bm[j] ? (t[(i + 1) * w + j + 1] ?? 0) + 1 : Math.max(t[(i + 1) * w + j] ?? 0, t[i * w + j + 1] ?? 0);147 }148 }149 let i = 0;150 let j = 0;151 while (i < n && j < m) {152 if (am[i] === bm[j]) {153 ctx(am[i] ?? '');154 i++;155 j++;156 } else if ((t[(i + 1) * w + j] ?? 0) >= (t[i * w + j + 1] ?? 0)) del(am[i++] ?? '');157 else add(bm[j++] ?? '');158 }159 while (i < n) del(am[i++] ?? '');160 while (j < m) add(bm[j++] ?? '');161 }162 for (let i = a.length - suf; i < a.length; i++) ctx(a[i] ?? '');163 return { header: null, lines };164}165166export function additionsOnly(content: string): DiffHunk {167 const rows = content.replace(/\r\n/g, '\n').split('\n');168 if (rows.length > 1 && rows[rows.length - 1] === '') rows.pop();169 return { header: null, lines: rows.map((text, i) => ({ type: 'add' as const, text, oldNo: null, newNo: i + 1 })) };170}171172export function fileFromHunk(path: string | null, hunk: DiffHunk, note: string | null = null): DiffFile {173 return {174 path,175 oldPath: path,176 note,177 hunks: [hunk],178 added: hunk.lines.filter((l) => l.type === 'add').length,179 removed: hunk.lines.filter((l) => l.type === 'del').length,180 };181}182