hyp / apps /ui /src /lib /diff-utils.ts
Leon4gr45's picture
Upload folder using huggingface_hub
1dbc34b verified
/**
* Shared diff parsing utilities.
*
* Extracted from commit-worktree-dialog, discard-worktree-changes-dialog,
* stash-changes-dialog and git-diff-panel to eliminate duplication.
*/
export interface ParsedDiffHunk {
header: string;
lines: {
type: 'context' | 'addition' | 'deletion' | 'header';
content: string;
lineNumber?: { old?: number; new?: number };
}[];
}
export interface ParsedFileDiff {
filePath: string;
hunks: ParsedDiffHunk[];
isNew?: boolean;
isDeleted?: boolean;
isRenamed?: boolean;
/** Pre-computed count of added lines across all hunks */
additions: number;
/** Pre-computed count of deleted lines across all hunks */
deletions: number;
}
/**
* Parse unified diff format into structured data.
*
* Note: The regex `diff --git a\/(.*?) b\/(.*)` uses a non-greedy match for
* the `a/` path and a greedy match for `b/`. This can mis-handle paths that
* literally contain " b/" or are quoted by git. In practice this covers the
* vast majority of real-world paths; exotic cases will fall back to "unknown".
*/
export function parseDiff(diffText: string): ParsedFileDiff[] {
if (!diffText) return [];
const files: ParsedFileDiff[] = [];
const lines = diffText.split('\n');
let currentFile: ParsedFileDiff | null = null;
let currentHunk: ParsedDiffHunk | null = null;
let oldLineNum = 0;
let newLineNum = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('diff --git')) {
if (currentFile) {
if (currentHunk) currentFile.hunks.push(currentHunk);
files.push(currentFile);
}
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
currentFile = {
filePath: match ? match[2] : 'unknown',
hunks: [],
additions: 0,
deletions: 0,
};
currentHunk = null;
continue;
}
if (line.startsWith('new file mode')) {
if (currentFile) currentFile.isNew = true;
continue;
}
if (line.startsWith('deleted file mode')) {
if (currentFile) currentFile.isDeleted = true;
continue;
}
if (line.startsWith('rename from') || line.startsWith('rename to')) {
if (currentFile) currentFile.isRenamed = true;
continue;
}
if (line.startsWith('index ') || line.startsWith('--- ') || line.startsWith('+++ ')) {
continue;
}
if (line.startsWith('@@')) {
if (currentHunk && currentFile) currentFile.hunks.push(currentHunk);
const hunkMatch = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
oldLineNum = hunkMatch ? parseInt(hunkMatch[1], 10) : 1;
newLineNum = hunkMatch ? parseInt(hunkMatch[2], 10) : 1;
currentHunk = {
header: line,
lines: [{ type: 'header', content: line }],
};
continue;
}
if (currentHunk) {
// Skip trailing empty line produced by split('\n') to avoid phantom context line
if (line === '' && i === lines.length - 1) {
continue;
}
if (line.startsWith('+')) {
currentHunk.lines.push({
type: 'addition',
content: line.substring(1),
lineNumber: { new: newLineNum },
});
newLineNum++;
if (currentFile) currentFile.additions++;
} else if (line.startsWith('-')) {
currentHunk.lines.push({
type: 'deletion',
content: line.substring(1),
lineNumber: { old: oldLineNum },
});
oldLineNum++;
if (currentFile) currentFile.deletions++;
} else if (line.startsWith(' ') || line === '') {
currentHunk.lines.push({
type: 'context',
content: line.substring(1) || '',
lineNumber: { old: oldLineNum, new: newLineNum },
});
oldLineNum++;
newLineNum++;
}
}
}
if (currentFile) {
if (currentHunk) currentFile.hunks.push(currentHunk);
files.push(currentFile);
}
return files;
}
/**
* Reconstruct old (original) and new (modified) file content from a single-file
* unified diff string. Used by the CodeMirror merge diff viewer which needs
* both document versions to compute inline highlighting.
*
* For new files (entire content is additions), oldContent will be empty.
* For deleted files (entire content is deletions), newContent will be empty.
*/
export function reconstructFilesFromDiff(diffText: string): {
oldContent: string;
newContent: string;
} {
if (!diffText) return { oldContent: '', newContent: '' };
const lines = diffText.split('\n');
const oldLines: string[] = [];
const newLines: string[] = [];
let inHunk = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Skip diff header lines
if (
line.startsWith('diff --git') ||
line.startsWith('index ') ||
line.startsWith('--- ') ||
line.startsWith('+++ ') ||
line.startsWith('new file mode') ||
line.startsWith('deleted file mode') ||
line.startsWith('rename from') ||
line.startsWith('rename to') ||
line.startsWith('similarity index') ||
line.startsWith('old mode') ||
line.startsWith('new mode')
) {
continue;
}
// Hunk header
if (line.startsWith('@@')) {
inHunk = true;
continue;
}
if (!inHunk) continue;
// Skip trailing empty line produced by split('\n')
if (line === '' && i === lines.length - 1) {
continue;
}
// "\ No newline at end of file" marker
if (line.startsWith('\\')) {
continue;
}
if (line.startsWith('+')) {
newLines.push(line.substring(1));
} else if (line.startsWith('-')) {
oldLines.push(line.substring(1));
} else {
// Context line (starts with space or is empty within hunk)
const content = line.startsWith(' ') ? line.substring(1) : line;
oldLines.push(content);
newLines.push(content);
}
}
return {
oldContent: oldLines.join('\n'),
newContent: newLines.join('\n'),
};
}
/**
* Split a combined multi-file diff string into per-file diff strings.
* Each entry in the returned array is a complete diff block for a single file.
*/
export function splitDiffByFile(
combinedDiff: string
): { filePath: string; diff: string; isNew: boolean; isDeleted: boolean }[] {
if (!combinedDiff) return [];
const results: { filePath: string; diff: string; isNew: boolean; isDeleted: boolean }[] = [];
const lines = combinedDiff.split('\n');
let currentLines: string[] = [];
let currentFilePath = '';
let currentIsNew = false;
let currentIsDeleted = false;
for (const line of lines) {
if (line.startsWith('diff --git')) {
// Push previous file if exists
if (currentLines.length > 0 && currentFilePath) {
results.push({
filePath: currentFilePath,
diff: currentLines.join('\n'),
isNew: currentIsNew,
isDeleted: currentIsDeleted,
});
}
currentLines = [line];
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
currentFilePath = match ? match[2] : 'unknown';
currentIsNew = false;
currentIsDeleted = false;
} else {
if (line.startsWith('new file mode')) currentIsNew = true;
if (line.startsWith('deleted file mode')) currentIsDeleted = true;
currentLines.push(line);
}
}
// Push last file
if (currentLines.length > 0 && currentFilePath) {
results.push({
filePath: currentFilePath,
diff: currentLines.join('\n'),
isNew: currentIsNew,
isDeleted: currentIsDeleted,
});
}
return results;
}