11import { TextAttributes } from '@opentui/core'
2+ import type { ReactNode } from 'react'
3+ import { useEffect , useState } from 'react'
24
35import { useTheme } from '../../hooks/use-theme'
6+ import { logger } from '../../utils/logger'
7+ import {
8+ highlightCode ,
9+ highlightCodeSync ,
10+ } from '../../utils/syntax-highlighter'
411
512interface DiffViewerProps {
613 diffText : string
14+ filePath : string | undefined
715}
816
917const DIFF_LINE_COLORS = {
1018 added : '#B6BD73' ,
1119 removed : '#BF6C69' ,
1220}
1321
14- const lineColor = ( line : string ) : { fg : string ; attrs ?: number } => {
22+ /**
23+ * Extract language from file path
24+ */
25+ const getLanguageFromPath = ( filePath : string ) : string | undefined => {
26+ const ext = filePath . split ( '.' ) . pop ( ) ?. toLowerCase ( )
27+ if ( ! ext ) return undefined
28+
29+ // Map common extensions to cli-highlight language names
30+ const langMap : Record < string , string > = {
31+ ts : 'typescript' ,
32+ tsx : 'typescript' ,
33+ js : 'javascript' ,
34+ jsx : 'javascript' ,
35+ py : 'python' ,
36+ rb : 'ruby' ,
37+ java : 'java' ,
38+ c : 'c' ,
39+ cpp : 'cpp' ,
40+ cc : 'cpp' ,
41+ cxx : 'cpp' ,
42+ h : 'cpp' ,
43+ hpp : 'cpp' ,
44+ cs : 'csharp' ,
45+ go : 'go' ,
46+ rs : 'rust' ,
47+ php : 'php' ,
48+ swift : 'swift' ,
49+ kt : 'kotlin' ,
50+ scala : 'scala' ,
51+ sh : 'bash' ,
52+ bash : 'bash' ,
53+ zsh : 'bash' ,
54+ json : 'json' ,
55+ xml : 'xml' ,
56+ html : 'html' ,
57+ css : 'css' ,
58+ scss : 'scss' ,
59+ sass : 'sass' ,
60+ md : 'markdown' ,
61+ yaml : 'yaml' ,
62+ yml : 'yaml' ,
63+ sql : 'sql' ,
64+ }
65+
66+ return langMap [ ext ]
67+ }
68+
69+ /**
70+ * Parse diff to extract file path from diff headers
71+ */
72+ const parseCurrentFile = (
73+ lines : string [ ] ,
74+ currentIndex : number ,
75+ ) : string | undefined => {
76+ // Look backwards from current line to find the most recent diff header
77+ for ( let i = currentIndex ; i >= 0 ; i -- ) {
78+ const line = lines [ i ]
79+ if ( line . startsWith ( 'diff --git' ) ) {
80+ // Extract file path from "diff --git a/path/to/file.ts b/path/to/file.ts"
81+ const match = line . match ( / d i f f - - g i t a \/ ( .+ ?) b \/ ( .+ ) / )
82+ if ( match ) {
83+ // Use the 'b' path (new file) as it's more relevant for added/modified files
84+ return match [ 2 ]
85+ }
86+ }
87+ if ( line . startsWith ( '+++' ) ) {
88+ // Extract from "+++ b/path/to/file.ts"
89+ const match = line . match ( / ^ \+ \+ \+ b \/ ( .+ ) / )
90+ if ( match ) {
91+ return match [ 1 ]
92+ }
93+ }
94+ }
95+ return undefined
96+ }
97+
98+ interface LineColorResult {
99+ fg : string
100+ attrs ?: number
101+ isDiffMarker : boolean
102+ }
103+
104+ const lineColor = ( line : string ) : LineColorResult => {
15105 if ( line . startsWith ( '@@' ) ) {
16- return { fg : 'cyan' , attrs : TextAttributes . BOLD }
106+ return { fg : 'cyan' , attrs : TextAttributes . BOLD , isDiffMarker : true }
17107 }
18108 if ( line . startsWith ( '+++' ) || line . startsWith ( '---' ) ) {
19- return { fg : 'gray' , attrs : TextAttributes . BOLD }
109+ return { fg : 'gray' , attrs : TextAttributes . BOLD , isDiffMarker : true }
20110 }
21111 if (
22112 line . startsWith ( 'diff ' ) ||
23113 line . startsWith ( 'index ' ) ||
24114 line . startsWith ( 'rename ' ) ||
25115 line . startsWith ( 'similarity ' )
26116 ) {
27- return { fg : 'gray' }
117+ return { fg : 'gray' , isDiffMarker : true }
28118 }
29119 if ( line . startsWith ( '+' ) ) {
30- return { fg : DIFF_LINE_COLORS . added }
120+ return { fg : DIFF_LINE_COLORS . added , isDiffMarker : false }
31121 }
32122 if ( line . startsWith ( '-' ) ) {
33- return { fg : DIFF_LINE_COLORS . removed }
123+ return { fg : DIFF_LINE_COLORS . removed , isDiffMarker : false }
34124 }
35125 if ( line . startsWith ( '\\' ) ) {
36- return { fg : 'gray' }
126+ return { fg : 'gray' , isDiffMarker : true }
37127 }
38- return { fg : '' }
128+ return { fg : '' , isDiffMarker : true }
39129}
40130
41- export const DiffViewer = ( { diffText } : DiffViewerProps ) => {
131+ /**
132+ * Render a diff line with syntax highlighting
133+ */
134+ const renderDiffLine = (
135+ line : string ,
136+ language : string | undefined ,
137+ colorInfo : LineColorResult ,
138+ theme : { foreground : string } ,
139+ highlightedCode ?: ReactNode ,
140+ ) : ReactNode => {
141+ const { fg, attrs, isDiffMarker } = colorInfo
142+ const resolvedFg = fg || theme . foreground
143+
144+ // For diff markers and context lines, just use plain coloring
145+ if ( ! language ) {
146+ return (
147+ < span fg = { resolvedFg } attributes = { attrs } >
148+ { line }
149+ </ span >
150+ )
151+ }
152+
153+ // For code lines, extract the code content (after the +/- marker)
154+ const prefix = line [ 0 ] === '+' ? '+' : line [ 0 ] === '-' ? '-' : ''
155+ const codeContent = line . slice ( prefix . length ) // Remove the +/- prefix
156+
157+ // Use pre-highlighted code if available, otherwise fallback to sync version
158+ const code = highlightedCode ?? highlightCodeSync ( codeContent , language , { } )
159+
160+ // Wrap the highlighted code with diff color overlay
161+ return (
162+ < span fg = { resolvedFg } >
163+ { prefix }
164+ { code }
165+ </ span >
166+ )
167+ }
168+
169+ export const DiffViewer = ( { diffText, filePath } : DiffViewerProps ) => {
42170 const theme = useTheme ( )
43171 const lines = diffText . split ( '\n' )
172+ const [ highlightedLines , setHighlightedLines ] = useState <
173+ Map < number , ReactNode >
174+ > ( new Map ( ) )
175+
176+ const language = filePath ? getLanguageFromPath ( filePath ) : undefined
177+ // Async highlight code lines progressively
178+ useEffect ( ( ) => {
179+ let ignore = false
180+ const newHighlights = new Map < number , ReactNode > ( )
181+
182+ const highlightAllLines = async ( ) => {
183+ logger . debug (
184+ { linesLength : lines . length , lines } ,
185+ '[DiffViewer] Starting async highlighting' ,
186+ )
187+
188+ const promises = lines . map ( async ( rawLine , idx ) => {
189+ const line = rawLine . length === 0 ? ' ' : rawLine
190+
191+ logger . debug (
192+ { idx, line, file : filePath , language } ,
193+ '[DiffViewer] Line' ,
194+ )
195+
196+ if ( ! language ) return
197+
198+ // Extract code content (after +/- marker)
199+ const codeContent = line . slice ( 1 )
200+
201+ logger . debug (
202+ { idx, line, language, content : codeContent . substring ( 0 , 50 ) } ,
203+ '[DiffViewer] Highlighting line' ,
204+ )
205+
206+ try {
207+ const highlighted = await highlightCode ( codeContent , language , { } )
208+ logger . debug (
209+ { idx, line, highlighted } ,
210+ '[DiffViewer] Successfully highlighted line' ,
211+ )
212+ if ( ! ignore ) {
213+ newHighlights . set ( idx , highlighted )
214+ }
215+ } catch ( error ) {
216+ logger . error (
217+ { idx, line, error } ,
218+ '[DiffViewer] Error highlighting line' ,
219+ )
220+ // Silently fall back to sync highlighting on error
221+ }
222+ } )
223+
224+ await Promise . all ( promises )
225+
226+ logger . debug (
227+ { highlights : newHighlights . size } ,
228+ '[DiffViewer] All highlighting complete' ,
229+ )
230+
231+ if ( ! ignore ) {
232+ setHighlightedLines ( newHighlights )
233+ }
234+ }
235+
236+ highlightAllLines ( )
237+
238+ return ( ) => {
239+ ignore = true
240+ }
241+ } , [ diffText ] )
44242
45243 return (
46244 < box
@@ -50,13 +248,17 @@ export const DiffViewer = ({ diffText }: DiffViewerProps) => {
50248 . filter ( ( rawLine ) => ! rawLine . startsWith ( '@@' ) )
51249 . map ( ( rawLine , idx ) => {
52250 const line = rawLine . length === 0 ? ' ' : rawLine
53- const { fg , attrs } = lineColor ( line )
54- const resolvedFg = fg || theme . foreground
251+ const colorInfo = lineColor ( line )
252+
55253 return (
56254 < text key = { `diff-line-${ idx } ` } style = { { wrapMode : 'none' } } >
57- < span fg = { resolvedFg } attributes = { attrs } >
58- { line }
59- </ span >
255+ { renderDiffLine (
256+ line ,
257+ language ,
258+ colorInfo ,
259+ theme ,
260+ highlightedLines . get ( idx ) ,
261+ ) }
60262 </ text >
61263 )
62264 } ) }
0 commit comments