266 lines
8.8 KiB
TypeScript
266 lines
8.8 KiB
TypeScript
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 };
|
|
}
|