import type { FileDiffData, DiffComment, AnnotationSide, CommentAnnotation } from "./types"; /** * Format line range for display (e.g., "L10" or "L10-15") */ export function formatLineRange(startLine: number, endLine: number): string { return startLine === endLine ? `L${startLine}` : `L${startLine}-${endLine}`; } /** * Frontend types that mirror backend FileMutation */ export interface FileMutation { type: "create" | "replace" | "patch" | "delete" | "rename"; content?: string; old_content?: string; new_content?: string; diff?: string; new_path?: string; start_line?: number; end_line?: number; } export interface ModifyFilePayload { file_path: string; mutations: FileMutation[]; } /** * Normalize a diff string by ensuring it has proper headers. * Required because backend diffs may not include full git headers. * * Detects new-file (`@@ -0,0 +X,Y @@`) and deleted-file (`@@ -X,Y +0,0 @@`) * hunks and emits the canonical `--- /dev/null` / `+++ /dev/null` markers. * @pierre/diffs 1.1.x relies on these to classify the change type; when both * sides point at `a/file` and `b/file` for a new-file patch the renderer * throws "deletionLine and additionLine are null" because it expects a * modification and there's no old content to read. */ export function normalizeDiffString(diff: string, filePath: string): string { if (!diff) return ""; const trimmed = diff.trim(); // Detect new/deleted file from the hunk header OR from the explicit // mode-change line — covers patches with no `@@` body (mode-only, // binary, or rename-with-no-delta). const isNewFile = /@@\s+-0,0\s+\+\d/.test(trimmed) || /^new file mode\b/m.test(trimmed); const isDeletedFile = /@@\s+-\d[\d,]*\s+\+0,0\s+@@/.test(trimmed) || /^deleted file mode\b/m.test(trimmed); // Strip every git-produced header line so we can re-emit canonical ones. // Covers diff --git, index, mode change, similarity/dissimilarity, rename // and copy markers, and the --- / +++ pair. Hunk content (lines starting // with @@) and everything after stays untouched. // Append `\n` before matching so the last header line in a header-only // diff (e.g. 100%-similarity rename with no `@@` body) still terminates // and gets stripped — otherwise it leaks into `body`. const body = (trimmed + "\n").replace( /^(?:(?:diff --git|index |(?:new|deleted) file mode |(?:old|new) mode |(?:similarity|dissimilarity) index |(?:rename|copy) (?:from|to) |Binary files |--- |\+\+\+ )[^\n]*\n)+/, "", ); const headers = [`diff --git a/${filePath} b/${filePath}`]; if (isNewFile) headers.push("new file mode 100644"); else if (isDeletedFile) headers.push("deleted file mode 100644"); headers.push( isNewFile ? "--- /dev/null" : `--- a/${filePath}`, isDeletedFile ? "+++ /dev/null" : `+++ b/${filePath}`, ); // Ensure body ends with a newline. @pierre/diffs 1.1.x tolerates a missing // EOF newline on real text content, but its hunk row generator can over- // count rows when the last line lacks a terminator, occasionally tripping // "deletionLine and additionLine are null" inside DiffHunksRenderer. const tail = body.endsWith("\n") ? "" : "\n"; return headers.join("\n") + "\n" + body + tail; } /** * Transform a backend FileMutation to FileDiffData for @pierre/diffs. * The DiffViewer component handles diff generation from content using the library. */ export function transformFileMutation(filePath: string, mutation: FileMutation): FileDiffData { const resolvedPath = mutation.new_path || filePath; return { filePath: resolvedPath, oldContent: mutation.old_content || "", newContent: mutation.new_content || mutation.content || "", diff: resolveMutationDiff(mutation, resolvedPath), additions: 0, deletions: 0, }; } /** Resolve the diff string for a mutation: explicit diff > create diff > undefined. */ function resolveMutationDiff(mutation: FileMutation, filePath: string): string | undefined { if (mutation.diff) return normalizeDiffString(mutation.diff, filePath); if (mutation.type === "create" && mutation.content) return generateCreateDiff(mutation.content, filePath); return undefined; } /** * Generate an all-additions diff for new file creation. * Shows the entire content as green (added) lines in the diff viewer. */ function generateCreateDiff(content: string, filePath: string): string { const lines = content.split("\n"); const header = [ `diff --git a/${filePath} b/${filePath}`, `new file mode 100644`, `--- /dev/null`, `+++ b/${filePath}`, `@@ -0,0 +1,${lines.length} @@`, ]; const addedLines = lines.map((l) => `+${l}`); return [...header, ...addedLines].join("\n"); } /** * Transform a git status diff string to FileDiffData. * Language detection is handled automatically by the library. */ export function transformGitDiff( filePath: string, diff: string, _status: "A" | "M" | "D" | "??" | string, ): FileDiffData { return { filePath, oldContent: "", newContent: "", diff: normalizeDiffString(diff, filePath), additions: 0, deletions: 0, }; } /** * Convert DiffComment[] to @pierre/diffs DiffLineAnnotation[] */ export function commentsToAnnotations(comments: DiffComment[]): CommentAnnotation[] { return comments.map((comment) => ({ side: comment.side, lineNumber: comment.endLine, // Anchor at the end of the range metadata: { comment, isEditing: false, }, })); } function isDiffHeader(line: string): boolean { return line.startsWith("diff --git") || line.startsWith("---") || line.startsWith("+++"); } function isInRange(lineNum: number, startLine: number, endLine: number): boolean { return lineNum >= startLine && lineNum <= endLine; } type DiffLineCounters = { currentOldLine: number; currentNewLine: number }; function processHunkHeader(line: string, counters: DiffLineCounters): boolean { const hunkMatch = line.match(/^@@\s*-(\d+)(?:,\d+)?\s*\+(\d+)(?:,\d+)?\s*@@/); if (!hunkMatch) return false; counters.currentOldLine = parseInt(hunkMatch[1], 10); counters.currentNewLine = parseInt(hunkMatch[2], 10); return true; } type ProcessDiffLineParams = { line: string; counters: DiffLineCounters; startLine: number; endLine: number; side: AnnotationSide; resultLines: string[]; }; function processDiffLine({ line, counters, startLine, endLine, side, resultLines, }: ProcessDiffLineParams): void { if (line.startsWith("+")) { if (side === "additions" && isInRange(counters.currentNewLine, startLine, endLine)) { resultLines.push(line.substring(1)); } counters.currentNewLine++; } else if (line.startsWith("-")) { if (side === "deletions" && isInRange(counters.currentOldLine, startLine, endLine)) { resultLines.push(line.substring(1)); } counters.currentOldLine++; } else if (line.startsWith(" ") || (!line.startsWith("@") && line !== "")) { const content = line.startsWith(" ") ? line.substring(1) : line; const lineNum = side === "additions" ? counters.currentNewLine : counters.currentOldLine; if (isInRange(lineNum, startLine, endLine)) resultLines.push(content); counters.currentOldLine++; counters.currentNewLine++; } } /** * Extract code content from diff for a line range */ export function extractCodeFromDiff( diff: string, startLine: number, endLine: number, side: AnnotationSide, ): string { const lines = diff.split("\n"); const resultLines: string[] = []; const counters: DiffLineCounters = { currentOldLine: 0, currentNewLine: 0 }; for (const line of lines) { if (processHunkHeader(line, counters)) continue; if (isDiffHeader(line)) continue; processDiffLine({ line, counters, startLine, endLine, side, resultLines }); } return resultLines.join("\n"); } /** * Extract code content from full file content for a line range */ export function extractCodeFromContent( content: string, startLine: number, endLine: number, ): string { const lines = content.split("\n"); return lines.slice(startLine - 1, endLine).join("\n"); } /** * Compute simple line-level diff stats between two strings. * Returns the number of added and deleted lines. */ export function computeLineDiffStats( original: string, current: string, ): { additions: number; deletions: number } { const originalLines = original.split("\n"); const currentLines = current.split("\n"); let additions = 0; let deletions = 0; const maxLen = Math.max(originalLines.length, currentLines.length); for (let i = 0; i < maxLen; i++) { const origLine = originalLines[i]; const currLine = currentLines[i]; if (origLine === undefined && currLine !== undefined) additions++; else if (origLine !== undefined && currLine === undefined) deletions++; else if (origLine !== currLine) { additions++; deletions++; } } return { additions, deletions }; }