import type { GitState } from "@/features/agent/contracts"; export type GitDiffPayload = Partial; export type DiffLineKind = "meta" | "context" | "add" | "del"; export type DiffLine = { kind: DiffLineKind; text: string; oldLine?: number; newLine?: number; }; export type DiffFile = { path: string; additions: number; deletions: number; lines: DiffLine[]; }; export type DiffViewMode = "unified" | "side-by-side" | "stacked"; const DIFF_LINE_CLASS_NAMES: Record = { add: "bg-(--color-diff-added-bg) text-(--fg)", context: "text-(--fg)", del: "bg-(--color-diff-removed-bg) text-(--fg)", meta: "bg-(--surface) text-(--dim)", }; const DIFF_LINE_PREFIXES: Record = { add: "+", context: " ", del: "-", meta: "", }; /** * Parse unified git diff text into display-ready files and line metadata. * @param diff - Raw unified diff text. * @returns Parsed diff files. */ export function parseUnifiedDiff(diff: string): DiffFile[] { const files: DiffFile[] = []; let current: DiffFile | null = null; let oldLine = 0; let newLine = 0; for (const line of diff.split("\n")) { if (line.startsWith("diff --git ")) { const match = line.match(/^diff --git a\/(.+?) b\/(.+)$/); current = { path: match?.[2] ?? line.replace("diff --git ", ""), additions: 0, deletions: 0, lines: [], }; files.push(current); continue; } if (!current) continue; if (line.startsWith("@@")) { const match = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/); oldLine = Number(match?.[1] ?? 0); newLine = Number(match?.[2] ?? 0); current.lines.push({ kind: "meta", text: line }); continue; } if (line.startsWith("+++ ") || line.startsWith("--- ")) { current.lines.push({ kind: "meta", text: line }); continue; } if (line.startsWith("+")) { current.additions += 1; current.lines.push({ kind: "add", text: line.slice(1), newLine }); newLine += 1; continue; } if (line.startsWith("-")) { current.deletions += 1; current.lines.push({ kind: "del", text: line.slice(1), oldLine }); oldLine += 1; continue; } current.lines.push({ kind: "context", text: line.startsWith(" ") ? line.slice(1) : line, oldLine, newLine, }); oldLine += 1; newLine += 1; } return files; } /** * Resolve header copy for the git diff panel. * @param payload - Loaded git diff payload, if any. * @param cwd - Active project directory. * @returns Header label text. */ export function gitDiffHeaderTitle(payload: GitDiffPayload | null, cwd: string | null): string { if (payload?.branch) { return payload.branch; } return cwd ? "Working tree diff" : "No directory"; } /** * Resolve a diff line row class from the line kind. * @param kind - Diff line kind. * @returns CSS class names for the row state. */ export function diffLineClassName(kind: DiffLineKind): string { return DIFF_LINE_CLASS_NAMES[kind]; } /** * Resolve a visible diff line prefix from the line kind. * @param kind - Diff line kind. * @returns Prefix shown before line text. */ export function diffLinePrefix(kind: DiffLineKind): string { return DIFF_LINE_PREFIXES[kind]; } export function pairDiffLines(file: DiffFile): Array<{ left?: DiffFile["lines"][number]; right?: DiffFile["lines"][number]; }> { const rows: Array<{ left?: DiffFile["lines"][number]; right?: DiffFile["lines"][number] }> = []; const pendingDeletes: DiffFile["lines"] = []; for (const line of file.lines) { if (line.kind === "del") { pendingDeletes.push(line); continue; } if (line.kind === "add") { rows.push({ left: pendingDeletes.shift(), right: line }); continue; } while (pendingDeletes.length > 0) rows.push({ left: pendingDeletes.shift() }); rows.push({ left: line, right: line }); } while (pendingDeletes.length > 0) rows.push({ left: pendingDeletes.shift() }); return rows; }