diff --git a/packages/cli/src/ui/components/Header.test.tsx b/packages/cli/src/ui/components/Header.test.tsx index 5200db17d4..0ef6704f3b 100644 --- a/packages/cli/src/ui/components/Header.test.tsx +++ b/packages/cli/src/ui/components/Header.test.tsx @@ -128,7 +128,12 @@ describe('
', () => { }, background: { primary: '', - diff: { added: '', removed: '' }, + diff: { + added: '', + addedHighlight: '', + removed: '', + removedHighlight: '', + }, }, border: { default: '', diff --git a/packages/cli/src/ui/components/messages/DiffRenderer.tsx b/packages/cli/src/ui/components/messages/DiffRenderer.tsx index 83b205ac76..364e60a1a1 100644 --- a/packages/cli/src/ui/components/messages/DiffRenderer.tsx +++ b/packages/cli/src/ui/components/messages/DiffRenderer.tsx @@ -8,6 +8,7 @@ import type React from 'react'; import { useMemo } from 'react'; import { Box, Text, useIsScreenReaderEnabled } from 'ink'; import crypto from 'node:crypto'; +import * as Diff from 'diff'; import { colorizeCode, colorizeLine } from '../../utils/CodeColorizer.js'; import { MaxSizedBox } from '../shared/MaxSizedBox.js'; import { theme as semanticTheme } from '../../semantic-colors.js'; @@ -21,6 +22,42 @@ interface DiffLine { content: string; } +interface DiffChangeGroup { + type: 'change'; + removed: DiffLine[]; + added: DiffLine[]; +} + +type GroupedDiffLine = DiffLine | DiffChangeGroup; + +function groupDiffLines(lines: DiffLine[]): GroupedDiffLine[] { + const grouped: GroupedDiffLine[] = []; + let i = 0; + while (i < lines.length) { + if (lines[i].type === 'del') { + const removed: DiffLine[] = []; + while (i < lines.length && lines[i].type === 'del') { + removed.push(lines[i]); + i++; + } + const added: DiffLine[] = []; + while (i < lines.length && lines[i].type === 'add') { + added.push(lines[i]); + i++; + } + if (added.length > 0) { + grouped.push({ type: 'change', removed, added }); + } else { + grouped.push(...removed); + } + } else { + grouped.push(lines[i]); + i++; + } + } + return grouped; +} + function parseDiffWithLineNumbers(diffContent: string): DiffLine[] { const lines = diffContent.split('\n'); const result: DiffLine[] = []; @@ -256,18 +293,27 @@ const renderDiffContent = ( ? `diff-box-${filename}` : `diff-box-${crypto.createHash('sha1').update(JSON.stringify(parsedLines)).digest('hex')}`; + const groupedLines = groupDiffLines(displayableLines); + let lastLineNumber: number | null = null; const MAX_CONTEXT_LINES_WITHOUT_GAP = 5; - const content = displayableLines.reduce( - (acc, line, index) => { - // Determine the relevant line number for gap calculation based on type + const content = groupedLines.reduce( + (acc, entry, index) => { + // Determine the relevant line number for gap calculation let relevantLineNumberForGapCalc: number | null = null; - if (line.type === 'add' || line.type === 'context') { - relevantLineNumberForGapCalc = line.newLine ?? null; - } else if (line.type === 'del') { - // For deletions, the gap is typically in relation to the original file's line numbering - relevantLineNumberForGapCalc = line.oldLine ?? null; + if ('type' in entry && entry.type === 'change') { + const firstLine = entry.removed[0] || entry.added[0]; + relevantLineNumberForGapCalc = + (firstLine.type === 'add' ? firstLine.newLine : firstLine.oldLine) ?? + null; + } else { + const line = entry; + if (line.type === 'add' || line.type === 'context') { + relevantLineNumberForGapCalc = line.newLine ?? null; + } else if (line.type === 'del') { + relevantLineNumberForGapCalc = line.oldLine ?? null; + } } if ( @@ -290,82 +336,102 @@ const renderDiffContent = ( ); } - const lineKey = `diff-line-${index}`; - let gutterNumStr = ''; - let prefixSymbol = ' '; + if ('type' in entry && entry.type === 'change') { + const removedText = entry.removed + .map((l) => l.content.substring(baseIndentation)) + .join('\n'); + const addedText = entry.added + .map((l) => l.content.substring(baseIndentation)) + .join('\n'); + const wordDiffs = Diff.diffWordsWithSpace(removedText, addedText); - switch (line.type) { - case 'add': - gutterNumStr = (line.newLine ?? '').toString(); - prefixSymbol = '+'; - lastLineNumber = line.newLine ?? null; - break; - case 'del': - gutterNumStr = (line.oldLine ?? '').toString(); - prefixSymbol = '-'; - // For deletions, update lastLineNumber based on oldLine if it's advancing. - // This helps manage gaps correctly if there are multiple consecutive deletions - // or if a deletion is followed by a context line far away in the original file. + // Render removed lines + const removedLinesParts = renderChangesForType( + 'del', + wordDiffs, + semanticTheme.background.diff.removedHighlight, + ); + entry.removed.forEach((line, i) => { + const displayContentParts = removedLinesParts[i] || []; + acc.push( + renderLine( + line, + `del-${index}-${i}`, + gutterWidth, + '-', + semanticTheme.background.diff.removed, + displayContentParts.length > 0 ? displayContentParts : undefined, + baseIndentation, + language, + ), + ); if (line.oldLine !== undefined) { lastLineNumber = line.oldLine; } - break; - case 'context': - gutterNumStr = (line.newLine ?? '').toString(); - prefixSymbol = ' '; + }); + + // Render added lines + const addedLinesParts = renderChangesForType( + 'add', + wordDiffs, + semanticTheme.background.diff.addedHighlight, + ); + entry.added.forEach((line, i) => { + const displayContentParts = addedLinesParts[i] || []; + acc.push( + renderLine( + line, + `add-${index}-${i}`, + gutterWidth, + '+', + semanticTheme.background.diff.added, + displayContentParts.length > 0 ? displayContentParts : undefined, + baseIndentation, + language, + ), + ); lastLineNumber = line.newLine ?? null; - break; - default: - return acc; + }); + } else { + const line = entry; + + let prefixSymbol = ' '; + let backgroundColor: string | undefined = undefined; + + switch (line.type) { + case 'add': + prefixSymbol = '+'; + backgroundColor = semanticTheme.background.diff.added; + lastLineNumber = line.newLine ?? null; + break; + case 'del': + prefixSymbol = '-'; + backgroundColor = semanticTheme.background.diff.removed; + if (line.oldLine !== undefined) { + lastLineNumber = line.oldLine; + } + break; + case 'context': + prefixSymbol = ' '; + lastLineNumber = line.newLine ?? null; + break; + default: + break; + } + + acc.push( + renderLine( + line, + `line-${index}`, + gutterWidth, + prefixSymbol, + backgroundColor, + undefined, + baseIndentation, + language, + ), + ); } - - const displayContent = line.content.substring(baseIndentation); - - const backgroundColor = - line.type === 'add' - ? semanticTheme.background.diff.added - : line.type === 'del' - ? semanticTheme.background.diff.removed - : undefined; - acc.push( - - - {gutterNumStr} - - {line.type === 'context' ? ( - <> - {prefixSymbol} - {colorizeLine(displayContent, language)} - - ) : ( - - - {prefixSymbol} - {' '} - {colorizeLine(displayContent, language)} - - )} - , - ); return acc; }, [], @@ -382,6 +448,85 @@ const renderDiffContent = ( ); }; +const renderLine = ( + line: DiffLine, + key: string, + gutterWidth: number, + prefixSymbol: string, + backgroundColor: string | undefined, + displayContentParts: React.ReactNode[] | undefined, + baseIndentation: number, + language: string | null, +) => { + const gutterNumStr = + (line.type === 'add' || line.type === 'context' + ? line.newLine + : line.oldLine + )?.toString() || ''; + const displayContent = line.content.substring(baseIndentation); + + return ( + + + {gutterNumStr} + + + + {prefixSymbol} + {' '} + {displayContentParts + ? displayContentParts + : colorizeLine(displayContent, language)} + + + ); +}; + +function renderChangesForType( + type: 'add' | 'del', + allChanges: Diff.Change[], + highlightColor: string | undefined, +) { + const lines: React.ReactNode[][] = [[]]; + + allChanges.forEach((change, changeIndex) => { + if (type === 'add' && change.removed) return; + if (type === 'del' && change.added) return; + + const isHighlighted = + (type === 'add' && change.added) || (type === 'del' && change.removed); + const color = isHighlighted ? highlightColor : undefined; + + const parts = change.value.split('\n'); + parts.forEach((part, partIndex) => { + if (partIndex > 0) lines.push([]); + lines[lines.length - 1].push( + + {part} + , + ); + }); + }); + return lines; +} + const getLanguageFromExtension = (extension: string): string | null => { const languageMap: { [key: string]: string } = { js: 'javascript', diff --git a/packages/cli/src/ui/components/messages/__snapshots__/DiffRenderer.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/DiffRenderer.test.tsx.snap index 03f55105a7..2401c9e217 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/DiffRenderer.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/DiffRenderer.test.tsx.snap @@ -13,8 +13,8 @@ exports[` > with useAlterna 'test'; 21 + const anotherNew = 'test'; -22 console.log('end of second - hunk');" +22 console.log('end of + second hunk');" `; exports[` > with useAlternateBuffer = false > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 80 and height 6 1`] = ` @@ -94,8 +94,8 @@ exports[` > with useAlterna 'test'; 21 + const anotherNew = 'test'; -22 console.log('end of second - hunk');" +22 console.log('end of + second hunk');" `; exports[` > with useAlternateBuffer = true > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 80 and height 6 1`] = ` diff --git a/packages/cli/src/ui/themes/github-dark.ts b/packages/cli/src/ui/themes/github-dark.ts index 28c14f598d..aff0645540 100644 --- a/packages/cli/src/ui/themes/github-dark.ts +++ b/packages/cli/src/ui/themes/github-dark.ts @@ -19,7 +19,9 @@ const githubDarkColors: ColorsTheme = { AccentYellow: '#FFAB70', AccentRed: '#F97583', DiffAdded: '#3C4636', + DiffAddedHighlight: '#5C6656', DiffRemoved: '#502125', + DiffRemovedHighlight: '#704145', Comment: '#6A737D', Gray: '#6A737D', DarkGray: interpolateColor('#6A737D', '#24292e', 0.5), diff --git a/packages/cli/src/ui/themes/github-light.ts b/packages/cli/src/ui/themes/github-light.ts index 264a9d7a88..33fbd854c5 100644 --- a/packages/cli/src/ui/themes/github-light.ts +++ b/packages/cli/src/ui/themes/github-light.ts @@ -19,7 +19,9 @@ const githubLightColors: ColorsTheme = { AccentYellow: '#990073', AccentRed: '#d14', DiffAdded: '#C6EAD8', + DiffAddedHighlight: '#A2D9B1', DiffRemoved: '#FFCCCC', + DiffRemovedHighlight: '#FFB3B3', Comment: '#998', Gray: '#999', DarkGray: interpolateColor('#999', '#f8f8f8', 0.5), diff --git a/packages/cli/src/ui/themes/no-color.ts b/packages/cli/src/ui/themes/no-color.ts index 7c22e68b9a..8f7c633c56 100644 --- a/packages/cli/src/ui/themes/no-color.ts +++ b/packages/cli/src/ui/themes/no-color.ts @@ -38,7 +38,9 @@ const noColorSemanticColors: SemanticColors = { primary: '', diff: { added: '', + addedHighlight: '', removed: '', + removedHighlight: '', }, }, border: { diff --git a/packages/cli/src/ui/themes/semantic-tokens.ts b/packages/cli/src/ui/themes/semantic-tokens.ts index 794ce745b6..98ec96cbbf 100644 --- a/packages/cli/src/ui/themes/semantic-tokens.ts +++ b/packages/cli/src/ui/themes/semantic-tokens.ts @@ -18,7 +18,9 @@ export interface SemanticColors { primary: string; diff: { added: string; + addedHighlight: string; removed: string; + removedHighlight: string; }; }; border: { @@ -50,7 +52,10 @@ export const lightSemanticColors: SemanticColors = { primary: lightTheme.Background, diff: { added: lightTheme.DiffAdded, + addedHighlight: lightTheme.DiffAddedHighlight ?? lightTheme.DiffAdded, removed: lightTheme.DiffRemoved, + removedHighlight: + lightTheme.DiffRemovedHighlight ?? lightTheme.DiffRemoved, }, }, border: { @@ -82,7 +87,9 @@ export const darkSemanticColors: SemanticColors = { primary: darkTheme.Background, diff: { added: darkTheme.DiffAdded, + addedHighlight: darkTheme.DiffAddedHighlight ?? darkTheme.DiffAdded, removed: darkTheme.DiffRemoved, + removedHighlight: darkTheme.DiffRemovedHighlight ?? darkTheme.DiffRemoved, }, }, border: { @@ -114,7 +121,9 @@ export const ansiSemanticColors: SemanticColors = { primary: ansiTheme.Background, diff: { added: ansiTheme.DiffAdded, + addedHighlight: ansiTheme.DiffAddedHighlight ?? ansiTheme.DiffAdded, removed: ansiTheme.DiffRemoved, + removedHighlight: ansiTheme.DiffRemovedHighlight ?? ansiTheme.DiffRemoved, }, }, border: { diff --git a/packages/cli/src/ui/themes/theme.ts b/packages/cli/src/ui/themes/theme.ts index 5ba11cb32d..81d485fbd2 100644 --- a/packages/cli/src/ui/themes/theme.ts +++ b/packages/cli/src/ui/themes/theme.ts @@ -26,7 +26,9 @@ export interface ColorsTheme { AccentYellow: string; AccentRed: string; DiffAdded: string; + DiffAddedHighlight?: string; DiffRemoved: string; + DiffRemovedHighlight?: string; Comment: string; Gray: string; DarkGray: string; @@ -48,7 +50,9 @@ export interface CustomTheme { primary?: string; diff?: { added?: string; + addedHighlight?: string; removed?: string; + removedHighlight?: string; }; }; border?: { @@ -77,7 +81,9 @@ export interface CustomTheme { AccentYellow?: string; AccentRed?: string; DiffAdded?: string; + DiffAddedHighlight?: string; DiffRemoved?: string; + DiffRemovedHighlight?: string; Comment?: string; Gray?: string; DarkGray?: string; @@ -96,7 +102,9 @@ export const lightTheme: ColorsTheme = { AccentYellow: '#D5A40A', AccentRed: '#DD4C4C', DiffAdded: '#C6EAD8', + DiffAddedHighlight: '#A2D9B1', DiffRemoved: '#FFCCCC', + DiffRemovedHighlight: '#FFB3B3', Comment: '#008000', Gray: '#97a0b0', DarkGray: interpolateColor('#97a0b0', '#FAFAFA', 0.5), @@ -115,7 +123,9 @@ export const darkTheme: ColorsTheme = { AccentYellow: '#F9E2AF', AccentRed: '#F38BA8', DiffAdded: '#28350B', + DiffAddedHighlight: '#435515', DiffRemoved: '#430000', + DiffRemovedHighlight: '#700000', Comment: '#6C7086', Gray: '#6C7086', DarkGray: interpolateColor('#6C7086', '#1E1E2E', 0.5), @@ -134,7 +144,9 @@ export const ansiTheme: ColorsTheme = { AccentYellow: 'yellow', AccentRed: 'red', DiffAdded: 'green', + DiffAddedHighlight: 'green', DiffRemoved: 'red', + DiffRemovedHighlight: 'red', Comment: 'gray', Gray: 'gray', DarkGray: 'gray', @@ -177,7 +189,11 @@ export class Theme { primary: this.colors.Background, diff: { added: this.colors.DiffAdded, + addedHighlight: + this.colors.DiffAddedHighlight ?? this.colors.DiffAdded, removed: this.colors.DiffRemoved, + removedHighlight: + this.colors.DiffRemovedHighlight ?? this.colors.DiffRemoved, }, }, border: { @@ -275,8 +291,20 @@ export function createCustomTheme(customTheme: CustomTheme): Theme { AccentRed: customTheme.status?.error ?? customTheme.AccentRed ?? '', DiffAdded: customTheme.background?.diff?.added ?? customTheme.DiffAdded ?? '', + DiffAddedHighlight: + customTheme.background?.diff?.addedHighlight ?? + customTheme.DiffAddedHighlight ?? + customTheme.background?.diff?.added ?? + customTheme.DiffAdded ?? + '', DiffRemoved: customTheme.background?.diff?.removed ?? customTheme.DiffRemoved ?? '', + DiffRemovedHighlight: + customTheme.background?.diff?.removedHighlight ?? + customTheme.DiffRemovedHighlight ?? + customTheme.background?.diff?.removed ?? + customTheme.DiffRemoved ?? + '', Comment: customTheme.ui?.comment ?? customTheme.Comment ?? '', Gray: customTheme.text?.secondary ?? customTheme.Gray ?? '', DarkGray: @@ -442,7 +470,15 @@ export function createCustomTheme(customTheme: CustomTheme): Theme { primary: customTheme.background?.primary ?? colors.Background, diff: { added: customTheme.background?.diff?.added ?? colors.DiffAdded, + addedHighlight: + customTheme.background?.diff?.addedHighlight ?? + colors.DiffAddedHighlight ?? + colors.DiffAdded, removed: customTheme.background?.diff?.removed ?? colors.DiffRemoved, + removedHighlight: + customTheme.background?.diff?.removedHighlight ?? + colors.DiffRemovedHighlight ?? + colors.DiffRemoved, }, }, border: {