/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import React from 'react'; import { Box, Text } from 'ink'; import { Colors } from '../../colors.js'; import crypto from 'crypto'; import { colorizeCode } from '../../utils/CodeColorizer.js'; interface DiffLine { type: 'add' | 'del' | 'context' | 'hunk' | 'other'; oldLine?: number; newLine?: number; content: string; } function parseDiffWithLineNumbers(diffContent: string): DiffLine[] { const lines = diffContent.split('\n'); const result: DiffLine[] = []; let currentOldLine = 0; let currentNewLine = 0; let inHunk = false; const hunkHeaderRegex = /^@@ -(\d+),?\d* \+(\d+),?\d* @@/; for (const line of lines) { const hunkMatch = line.match(hunkHeaderRegex); if (hunkMatch) { currentOldLine = parseInt(hunkMatch[1], 10); currentNewLine = parseInt(hunkMatch[2], 10); inHunk = true; result.push({ type: 'hunk', content: line }); // We need to adjust the starting point because the first line number applies to the *first* actual line change/context, // but we increment *before* pushing that line. So decrement here. currentOldLine--; currentNewLine--; continue; } if (!inHunk) { // Skip standard Git header lines more robustly if ( line.startsWith('--- ') || line.startsWith('+++ ') || line.startsWith('diff --git') || line.startsWith('index ') || line.startsWith('similarity index') || line.startsWith('rename from') || line.startsWith('rename to') || line.startsWith('new file mode') || line.startsWith('deleted file mode') ) continue; // If it's not a hunk or header, skip (or handle as 'other' if needed) continue; } if (line.startsWith('+')) { currentNewLine++; // Increment before pushing result.push({ type: 'add', newLine: currentNewLine, content: line.substring(1), }); } else if (line.startsWith('-')) { currentOldLine++; // Increment before pushing result.push({ type: 'del', oldLine: currentOldLine, content: line.substring(1), }); } else if (line.startsWith(' ')) { currentOldLine++; // Increment before pushing currentNewLine++; result.push({ type: 'context', oldLine: currentOldLine, newLine: currentNewLine, content: line.substring(1), }); } else if (line.startsWith('\\')) { // Handle "\ No newline at end of file" result.push({ type: 'other', content: line }); } } return result; } interface DiffRendererProps { diffContent: string; filename?: string; tabWidth?: number; } const DEFAULT_TAB_WIDTH = 4; // Spaces per tab for normalization export const DiffRenderer: React.FC = ({ diffContent, filename, tabWidth = DEFAULT_TAB_WIDTH, }) => { if (!diffContent || typeof diffContent !== 'string') { return No diff content.; } const parsedLines = parseDiffWithLineNumbers(diffContent); if (parsedLines.length === 0) { return ( No changes detected. ); } // Check if the diff represents a new file (only additions and header lines) const isNewFile = parsedLines.every( (line) => line.type === 'add' || line.type === 'hunk' || line.type === 'other' || line.content.startsWith('diff --git') || line.content.startsWith('new file mode'), ); let renderedOutput; if (isNewFile) { // Extract only the added lines' content const addedContent = parsedLines .filter((line) => line.type === 'add') .map((line) => line.content) .join('\n'); // Attempt to infer language from filename, default to plain text if no filename const fileExtension = filename?.split('.').pop() || null; const language = fileExtension ? getLanguageFromExtension(fileExtension) : null; renderedOutput = colorizeCode(addedContent, language); } else { renderedOutput = renderDiffContent(parsedLines, filename, tabWidth); } return renderedOutput; }; const renderDiffContent = ( parsedLines: DiffLine[], filename?: string, tabWidth = DEFAULT_TAB_WIDTH, ) => { // 1. Normalize whitespace (replace tabs with spaces) *before* further processing const normalizedLines = parsedLines.map((line) => ({ ...line, content: line.content.replace(/\t/g, ' '.repeat(tabWidth)), })); // Filter out non-displayable lines (hunks, potentially 'other') using the normalized list const displayableLines = normalizedLines.filter( (l) => l.type !== 'hunk' && l.type !== 'other', ); if (displayableLines.length === 0) { return ( No changes detected. ); } // Calculate the minimum indentation across all displayable lines let baseIndentation = Infinity; // Start high to find the minimum for (const line of displayableLines) { // Only consider lines with actual content for indentation calculation if (line.content.trim() === '') continue; const firstCharIndex = line.content.search(/\S/); // Find index of first non-whitespace char const currentIndent = firstCharIndex === -1 ? 0 : firstCharIndex; // Indent is 0 if no non-whitespace found baseIndentation = Math.min(baseIndentation, currentIndent); } // If baseIndentation remained Infinity (e.g., no displayable lines with content), default to 0 if (!isFinite(baseIndentation)) { baseIndentation = 0; } const key = filename ? `diff-box-${filename}` : `diff-box-${crypto.createHash('sha1').update(JSON.stringify(parsedLines)).digest('hex')}`; return ( {/* Iterate over the lines that should be displayed (already normalized) */} {displayableLines.map((line, index) => { const key = `diff-line-${index}`; let gutterNumStr = ''; let color: string | undefined = undefined; let prefixSymbol = ' '; let dim = false; switch (line.type) { case 'add': gutterNumStr = (line.newLine ?? '').toString(); color = 'green'; prefixSymbol = '+'; break; case 'del': gutterNumStr = (line.oldLine ?? '').toString(); color = 'red'; prefixSymbol = '-'; break; case 'context': // Show new line number for context lines in gutter gutterNumStr = (line.newLine ?? '').toString(); dim = true; prefixSymbol = ' '; break; default: throw new Error(`Unknown line type: ${line.type}`); } // Render the line content *after* stripping the calculated *minimum* baseIndentation. // The line.content here is already the tab-normalized version. const displayContent = line.content.substring(baseIndentation); return ( // Using your original rendering structure {gutterNumStr} {prefixSymbol}{' '} {displayContent} ); })} ); }; const getLanguageFromExtension = (extension: string): string | null => { const languageMap: { [key: string]: string } = { js: 'javascript', ts: 'typescript', py: 'python', json: 'json', css: 'css', html: 'html', sh: 'bash', md: 'markdown', yaml: 'yaml', yml: 'yaml', txt: 'plaintext', java: 'java', c: 'c', cpp: 'cpp', rb: 'ruby', }; return languageMap[extension] || null; // Return null if extension not found };