SPB Git

Repository Public

diff.ts 6.7 KB 182 lines typescript

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
Files main
Menu

Theme