StructuredDiff.tsx
1 import { Box, Text } from 'ink' 2 import * as React from 'react' 3 import { Hunk } from 'diff' 4 import { getTheme, ThemeNames } from '../utils/theme.js' 5 import { useMemo } from 'react' 6 import { wrapText } from '../utils/format.js' 7 8 type Props = { 9 patch: Hunk 10 dim: boolean 11 width: number 12 overrideTheme?: ThemeNames // custom theme for previews 13 } 14 15 export function StructuredDiff({ 16 patch, 17 dim, 18 width, 19 overrideTheme, 20 }: Props): React.ReactNode { 21 const diff = useMemo( 22 () => formatDiff(patch.lines, patch.oldStart, width, dim, overrideTheme), 23 [patch.lines, patch.oldStart, width, dim, overrideTheme], 24 ) 25 26 return diff.map((_, i) => <Box key={i}>{_}</Box>) 27 } 28 29 function formatDiff( 30 lines: string[], 31 startingLineNumber: number, 32 width: number, 33 dim: boolean, 34 overrideTheme?: ThemeNames, 35 ): React.ReactNode[] { 36 const theme = getTheme(overrideTheme) 37 38 const ls = numberDiffLines( 39 lines.map(code => { 40 if (code.startsWith('+')) { 41 return { 42 code: ' ' + code.slice(1), 43 i: 0, 44 type: 'add', 45 } 46 } 47 if (code.startsWith('-')) { 48 return { 49 code: ' ' + code.slice(1), 50 i: 0, 51 type: 'remove', 52 } 53 } 54 return { code, i: 0, type: 'nochange' } 55 }), 56 startingLineNumber, 57 ) 58 59 const maxLineNumber = Math.max(...ls.map(({ i }) => i)) 60 const maxWidth = maxLineNumber.toString().length 61 62 return ls.flatMap(({ type, code, i }) => { 63 const wrappedLines = wrapText(code, width - maxWidth) 64 return wrappedLines.map((line, lineIndex) => { 65 const key = `${type}-${i}-${lineIndex}` 66 switch (type) { 67 case 'add': 68 return ( 69 <Text key={key}> 70 <LineNumber 71 i={lineIndex === 0 ? i : undefined} 72 width={maxWidth} 73 /> 74 <Text 75 color={overrideTheme ? theme.text : undefined} 76 backgroundColor={ 77 dim ? theme.diff.addedDimmed : theme.diff.added 78 } 79 dimColor={dim} 80 > 81 {line} 82 </Text> 83 </Text> 84 ) 85 case 'remove': 86 return ( 87 <Text key={key}> 88 <LineNumber 89 i={lineIndex === 0 ? i : undefined} 90 width={maxWidth} 91 /> 92 <Text 93 color={overrideTheme ? theme.text : undefined} 94 backgroundColor={ 95 dim ? theme.diff.removedDimmed : theme.diff.removed 96 } 97 dimColor={dim} 98 > 99 {line} 100 </Text> 101 </Text> 102 ) 103 case 'nochange': 104 return ( 105 <Text key={key}> 106 <LineNumber 107 i={lineIndex === 0 ? i : undefined} 108 width={maxWidth} 109 /> 110 <Text 111 color={overrideTheme ? theme.text : undefined} 112 dimColor={dim} 113 > 114 {line} 115 </Text> 116 </Text> 117 ) 118 } 119 }) 120 }) 121 } 122 123 function LineNumber({ 124 i, 125 width, 126 }: { 127 i: number | undefined 128 width: number 129 }): React.ReactNode { 130 return ( 131 <Text color={getTheme().secondaryText}> 132 {i !== undefined ? i.toString().padStart(width) : ' '.repeat(width)}{' '} 133 </Text> 134 ) 135 } 136 137 function numberDiffLines( 138 diff: { code: string; type: string }[], 139 startLine: number, 140 ): { code: string; type: string; i: number }[] { 141 let i = startLine 142 const result: { code: string; type: string; i: number }[] = [] 143 const queue = [...diff] 144 145 while (queue.length > 0) { 146 const { code, type } = queue.shift()! 147 const line = { 148 code: code, 149 type, 150 i, 151 } 152 153 // Update counters based on change type 154 switch (type) { 155 case 'nochange': 156 i++ 157 result.push(line) 158 break 159 case 'add': 160 i++ 161 result.push(line) 162 break 163 case 'remove': { 164 result.push(line) 165 let numRemoved = 0 166 while (queue[0]?.type === 'remove') { 167 i++ 168 const { code, type } = queue.shift()! 169 const line = { 170 code: code, 171 type, 172 i, 173 } 174 result.push(line) 175 numRemoved++ 176 } 177 i -= numRemoved 178 break 179 } 180 } 181 } 182 183 return result 184 }