/ src / components / StructuredDiff.tsx
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  }