@@ -157,6 +157,15 @@ function guessLang(filename?: string): string {
157157// Syntax Highlighting
158158// ============================================================================
159159
160+ function escapeHtml ( text : string ) : string {
161+ return text
162+ . replace ( / & / g, "&" )
163+ . replace ( / < / g, "<" )
164+ . replace ( / > / g, ">" )
165+ . replace ( / " / g, """ )
166+ . replace ( / ' / g, "'" ) ;
167+ }
168+
160169function hastToHtml ( node : any ) : string {
161170 if ( node . type === "text" ) {
162171 return escapeHtml ( node . value ) ;
@@ -171,15 +180,6 @@ function hastToHtml(node: any): string {
171180 return "" ;
172181}
173182
174- function escapeHtml ( text : string ) : string {
175- return text
176- . replace ( / & / g, "&" )
177- . replace ( / < / g, "<" )
178- . replace ( / > / g, ">" )
179- . replace ( / " / g, """ )
180- . replace ( / ' / g, "'" ) ;
181- }
182-
183183function highlight ( code : string , lang : string ) : string {
184184 try {
185185 const tree = refractor . highlight ( code , lang ) ;
@@ -189,6 +189,95 @@ function highlight(code: string, lang: string): string {
189189 }
190190}
191191
192+ /**
193+ * Highlight an entire file and return an array of HTML strings, one per line.
194+ * This handles multi-line constructs (strings, comments) correctly by
195+ * closing and reopening tags at line boundaries.
196+ */
197+ interface OpenTag {
198+ tagName : string ;
199+ className ?: string ;
200+ }
201+
202+ function highlightFileByLines ( content : string , lang : string ) : string [ ] {
203+ if ( ! content ) return [ ] ;
204+
205+ try {
206+ const tree = refractor . highlight ( content , lang ) ;
207+ const lines : string [ ] = [ ] ;
208+ let currentLine : string [ ] = [ ] ;
209+ const openTags : OpenTag [ ] = [ ] ;
210+
211+ function closeAllTags ( ) : string {
212+ return [ ...openTags ]
213+ . reverse ( )
214+ . map ( ( t ) => `</${ t . tagName } >` )
215+ . join ( "" ) ;
216+ }
217+
218+ function openAllTags ( ) : string {
219+ return openTags
220+ . map ( ( t ) => {
221+ const cls = t . className ? ` class="${ t . className } "` : "" ;
222+ return `<${ t . tagName } ${ cls } >` ;
223+ } )
224+ . join ( "" ) ;
225+ }
226+
227+ function processText ( text : string ) {
228+ const parts = text . split ( "\n" ) ;
229+ for ( let i = 0 ; i < parts . length ; i ++ ) {
230+ if ( i > 0 ) {
231+ // End current line with closing tags
232+ currentLine . push ( closeAllTags ( ) ) ;
233+ lines . push ( currentLine . join ( "" ) ) ;
234+ // Start new line with opening tags
235+ currentLine = [ openAllTags ( ) ] ;
236+ }
237+ if ( parts [ i ] ) {
238+ currentLine . push ( escapeHtml ( parts [ i ] ) ) ;
239+ }
240+ }
241+ }
242+
243+ function walkNode ( node : any ) {
244+ if ( node . type === "text" ) {
245+ processText ( node . value ) ;
246+ } else if ( node . type === "element" ) {
247+ const { tagName, properties, children } = node ;
248+ const className = ( properties ?. className as string [ ] | undefined ) ?. join (
249+ " "
250+ ) ;
251+ const tag : OpenTag = { tagName, className } ;
252+
253+ // Open tag
254+ const cls = className ? ` class="${ className } "` : "" ;
255+ currentLine . push ( `<${ tagName } ${ cls } >` ) ;
256+ openTags . push ( tag ) ;
257+
258+ // Process children
259+ children . forEach ( walkNode ) ;
260+
261+ // Close tag
262+ openTags . pop ( ) ;
263+ currentLine . push ( `</${ tagName } >` ) ;
264+ }
265+ }
266+
267+ tree . children . forEach ( walkNode ) ;
268+
269+ // Don't forget the last line
270+ if ( currentLine . length > 0 ) {
271+ lines . push ( currentLine . join ( "" ) ) ;
272+ }
273+
274+ return lines ;
275+ } catch {
276+ // Fallback: escape each line
277+ return content . split ( "\n" ) . map ( escapeHtml ) ;
278+ }
279+ }
280+
192281/**
193282 * Highlight a range of lines from file content.
194283 * Returns an array of DiffLine objects with syntax highlighting.
@@ -201,12 +290,18 @@ export function highlightFileLines(
201290) : DiffLine [ ] {
202291 const language = guessLang ( filename ) ;
203292 const allLines = content . split ( "\n" ) ;
293+
294+ // Pre-highlight the entire file for proper context
295+ const highlightedLines = highlightFileByLines ( content , language ) ;
296+
204297 const result : DiffLine [ ] = [ ] ;
205298
206299 for ( let i = 0 ; i < count ; i ++ ) {
207300 const lineNum = startLine + i ;
208301 const lineContent = allLines [ lineNum - 1 ] ?? "" ;
209- const highlighted = highlight ( lineContent , language ) ;
302+ // Use pre-highlighted HTML, fallback to individual highlighting
303+ const highlighted =
304+ highlightedLines [ lineNum - 1 ] ?? highlight ( lineContent , language ) ;
210305
211306 result . push ( {
212307 type : "normal" ,
@@ -549,7 +644,9 @@ export function parseDiffWithHighlighting(
549644 patch : string ,
550645 filename : string ,
551646 previousFilename ?: string ,
552- cacheKey ?: string
647+ cacheKey ?: string ,
648+ oldContent ?: string ,
649+ newContent ?: string
553650) : ParsedDiff {
554651 // Check cache
555652 if ( cacheKey && diffCache . has ( cacheKey ) ) {
@@ -570,6 +667,19 @@ ${patch}`;
570667 }
571668
572669 const language = guessLang ( filename ) ;
670+ const prevLanguage = previousFilename
671+ ? guessLang ( previousFilename )
672+ : language ;
673+
674+ // Pre-highlight full files if content is provided
675+ // This ensures proper highlighting for multi-line constructs (strings, comments, etc.)
676+ const oldHighlightedLines = oldContent
677+ ? highlightFileByLines ( oldContent , prevLanguage )
678+ : null ;
679+ const newHighlightedLines = newContent
680+ ? highlightFileByLines ( newContent , language )
681+ : null ;
682+
573683 const rawHunks = insertSkipBlocks (
574684 file . hunks . map ( ( hunk ) => parseHunk ( hunk , opts ) )
575685 ) ;
@@ -598,15 +708,63 @@ ${patch}`;
598708 ? ( line as any ) . lineNumber
599709 : undefined ;
600710
711+ // For lines with a single segment (no inline diff), use pre-highlighted content
712+ // For lines with multiple segments (inline diff), highlight each segment
713+ const hasSingleSegment = line . content . length === 1 ;
714+ const singleSegmentIsNormal =
715+ hasSingleSegment && line . content [ 0 ] . type === "normal" ;
716+
601717 return {
602718 type : line . type ,
603719 oldLineNumber : oldNum ,
604720 newLineNumber : newNum ,
605- content : line . content . map ( ( seg ) => ( {
606- value : seg . value ,
607- html : highlight ( seg . value , language ) ,
608- type : seg . type ,
609- } ) ) ,
721+ content : line . content . map ( ( seg ) => {
722+ let html : string ;
723+
724+ // Try to use pre-highlighted content for better context
725+ if ( singleSegmentIsNormal ) {
726+ // Use pre-highlighted line if available
727+ if (
728+ line . type === "delete" &&
729+ oldHighlightedLines &&
730+ oldNum !== undefined
731+ ) {
732+ html =
733+ oldHighlightedLines [ oldNum - 1 ] ??
734+ highlight ( seg . value , prevLanguage ) ;
735+ } else if (
736+ line . type === "insert" &&
737+ newHighlightedLines &&
738+ newNum !== undefined
739+ ) {
740+ html =
741+ newHighlightedLines [ newNum - 1 ] ??
742+ highlight ( seg . value , language ) ;
743+ } else if (
744+ line . type === "normal" &&
745+ newHighlightedLines &&
746+ newNum !== undefined
747+ ) {
748+ // For normal lines, prefer new file highlighting (same content in both)
749+ html =
750+ newHighlightedLines [ newNum - 1 ] ??
751+ highlight ( seg . value , language ) ;
752+ } else {
753+ html = highlight ( seg . value , language ) ;
754+ }
755+ } else {
756+ // Multiple segments (inline diff) - highlight each segment individually
757+ // This is acceptable since inline diffs are usually small
758+ const segLang = seg . type === "delete" ? prevLanguage : language ;
759+ html = highlight ( seg . value , segLang ) ;
760+ }
761+
762+ return {
763+ value : seg . value ,
764+ html,
765+ type : seg . type ,
766+ } ;
767+ } ) ,
610768 } ;
611769 } ) ,
612770 } ;
0 commit comments