diffview.go

  1package diffview
  2
  3import (
  4	"fmt"
  5	"image/color"
  6	"strconv"
  7	"strings"
  8
  9	"github.com/alecthomas/chroma/v2"
 10	"github.com/alecthomas/chroma/v2/lexers"
 11	"github.com/aymanbagabas/go-udiff"
 12	"github.com/charmbracelet/lipgloss/v2"
 13	"github.com/charmbracelet/x/ansi"
 14	"github.com/zeebo/xxh3"
 15)
 16
 17const (
 18	leadingSymbolsSize = 2
 19	lineNumPadding     = 1
 20)
 21
 22type file struct {
 23	path    string
 24	content string
 25}
 26
 27type layout int
 28
 29const (
 30	layoutUnified layout = iota + 1
 31	layoutSplit
 32)
 33
 34// DiffView represents a view for displaying differences between two files.
 35type DiffView struct {
 36	layout          layout
 37	before          file
 38	after           file
 39	contextLines    int
 40	lineNumbers     bool
 41	height          int
 42	width           int
 43	xOffset         int
 44	yOffset         int
 45	infiniteYScroll bool
 46	style           Style
 47	tabWidth        int
 48	chromaStyle     *chroma.Style
 49
 50	isComputed bool
 51	err        error
 52	unified    udiff.UnifiedDiff
 53	edits      []udiff.Edit
 54
 55	splitHunks []splitHunk
 56
 57	totalLines      int
 58	codeWidth       int
 59	fullCodeWidth   int  // with leading symbols
 60	extraColOnAfter bool // add extra column on after panel
 61	beforeNumDigits int
 62	afterNumDigits  int
 63
 64	// Cache lexer to avoid expensive file pattern matching on every line
 65	cachedLexer chroma.Lexer
 66
 67	// Cache highlighted lines to avoid re-highlighting the same content
 68	// Key: hash of (content + background color), Value: highlighted string
 69	syntaxCache map[string]string
 70}
 71
 72// New creates a new DiffView with default settings.
 73func New() *DiffView {
 74	dv := &DiffView{
 75		layout:       layoutUnified,
 76		contextLines: udiff.DefaultContextLines,
 77		lineNumbers:  true,
 78		tabWidth:     8,
 79		syntaxCache:  make(map[string]string),
 80	}
 81	dv.style = DefaultDarkStyle()
 82	return dv
 83}
 84
 85// Unified sets the layout of the DiffView to unified.
 86func (dv *DiffView) Unified() *DiffView {
 87	dv.layout = layoutUnified
 88	return dv
 89}
 90
 91// Split sets the layout of the DiffView to split (side-by-side).
 92func (dv *DiffView) Split() *DiffView {
 93	dv.layout = layoutSplit
 94	return dv
 95}
 96
 97// Before sets the "before" file for the DiffView.
 98func (dv *DiffView) Before(path, content string) *DiffView {
 99	dv.before = file{path: path, content: content}
100	// Clear caches when content changes
101	dv.clearCaches()
102	return dv
103}
104
105// After sets the "after" file for the DiffView.
106func (dv *DiffView) After(path, content string) *DiffView {
107	dv.after = file{path: path, content: content}
108	// Clear caches when content changes
109	dv.clearCaches()
110	return dv
111}
112
113// clearCaches clears all caches when content or major settings change.
114func (dv *DiffView) clearCaches() {
115	dv.cachedLexer = nil
116	dv.clearSyntaxCache()
117	dv.isComputed = false
118}
119
120// ContextLines sets the number of context lines for the DiffView.
121func (dv *DiffView) ContextLines(contextLines int) *DiffView {
122	dv.contextLines = contextLines
123	return dv
124}
125
126// Style sets the style for the DiffView.
127func (dv *DiffView) Style(style Style) *DiffView {
128	dv.style = style
129	return dv
130}
131
132// LineNumbers sets whether to display line numbers in the DiffView.
133func (dv *DiffView) LineNumbers(lineNumbers bool) *DiffView {
134	dv.lineNumbers = lineNumbers
135	return dv
136}
137
138// Height sets the height of the DiffView.
139func (dv *DiffView) Height(height int) *DiffView {
140	dv.height = height
141	return dv
142}
143
144// Width sets the width of the DiffView.
145func (dv *DiffView) Width(width int) *DiffView {
146	dv.width = width
147	return dv
148}
149
150// XOffset sets the horizontal offset for the DiffView.
151func (dv *DiffView) XOffset(xOffset int) *DiffView {
152	dv.xOffset = xOffset
153	return dv
154}
155
156// YOffset sets the vertical offset for the DiffView.
157func (dv *DiffView) YOffset(yOffset int) *DiffView {
158	dv.yOffset = yOffset
159	return dv
160}
161
162// InfiniteYScroll allows the YOffset to scroll beyond the last line.
163func (dv *DiffView) InfiniteYScroll(infiniteYScroll bool) *DiffView {
164	dv.infiniteYScroll = infiniteYScroll
165	return dv
166}
167
168// TabWidth sets the tab width. Only relevant for code that contains tabs, like
169// Go code.
170func (dv *DiffView) TabWidth(tabWidth int) *DiffView {
171	dv.tabWidth = tabWidth
172	return dv
173}
174
175// ChromaStyle sets the chroma style for syntax highlighting.
176// If nil, no syntax highlighting will be applied.
177func (dv *DiffView) ChromaStyle(style *chroma.Style) *DiffView {
178	dv.chromaStyle = style
179	// Clear syntax cache when style changes since highlighting will be different
180	dv.clearSyntaxCache()
181	return dv
182}
183
184// clearSyntaxCache clears the syntax highlighting cache.
185func (dv *DiffView) clearSyntaxCache() {
186	if dv.syntaxCache != nil {
187		// Clear the map but keep it allocated
188		for k := range dv.syntaxCache {
189			delete(dv.syntaxCache, k)
190		}
191	}
192}
193
194// String returns the string representation of the DiffView.
195func (dv *DiffView) String() string {
196	dv.replaceTabs()
197	if err := dv.computeDiff(); err != nil {
198		return err.Error()
199	}
200	dv.convertDiffToSplit()
201	dv.adjustStyles()
202	dv.detectNumDigits()
203	dv.detectTotalLines()
204	dv.preventInfiniteYScroll()
205
206	if dv.width <= 0 {
207		dv.detectCodeWidth()
208	} else {
209		dv.resizeCodeWidth()
210	}
211
212	style := lipgloss.NewStyle()
213	if dv.width > 0 {
214		style = style.MaxWidth(dv.width)
215	}
216	if dv.height > 0 {
217		style = style.MaxHeight(dv.height)
218	}
219
220	switch dv.layout {
221	case layoutUnified:
222		return style.Render(strings.TrimSuffix(dv.renderUnified(), "\n"))
223	case layoutSplit:
224		return style.Render(strings.TrimSuffix(dv.renderSplit(), "\n"))
225	default:
226		panic("unknown diffview layout")
227	}
228}
229
230// replaceTabs replaces tabs in the before and after file contents with spaces
231// according to the specified tab width.
232func (dv *DiffView) replaceTabs() {
233	spaces := strings.Repeat(" ", dv.tabWidth)
234	dv.before.content = strings.ReplaceAll(dv.before.content, "\t", spaces)
235	dv.after.content = strings.ReplaceAll(dv.after.content, "\t", spaces)
236}
237
238// computeDiff computes the differences between the "before" and "after" files.
239func (dv *DiffView) computeDiff() error {
240	if dv.isComputed {
241		return dv.err
242	}
243	dv.isComputed = true
244	dv.edits = udiff.Strings(
245		dv.before.content,
246		dv.after.content,
247	)
248	dv.unified, dv.err = udiff.ToUnifiedDiff(
249		dv.before.path,
250		dv.after.path,
251		dv.before.content,
252		dv.edits,
253		dv.contextLines,
254	)
255	return dv.err
256}
257
258// convertDiffToSplit converts the unified diff to a split diff if the layout is
259// set to split.
260func (dv *DiffView) convertDiffToSplit() {
261	if dv.layout != layoutSplit {
262		return
263	}
264
265	dv.splitHunks = make([]splitHunk, len(dv.unified.Hunks))
266	for i, h := range dv.unified.Hunks {
267		dv.splitHunks[i] = hunkToSplit(h)
268	}
269}
270
271// adjustStyles adjusts adds padding and alignment to the styles.
272func (dv *DiffView) adjustStyles() {
273	setPadding := func(s lipgloss.Style) lipgloss.Style {
274		return s.Padding(0, lineNumPadding).Align(lipgloss.Right)
275	}
276	dv.style.MissingLine.LineNumber = setPadding(dv.style.MissingLine.LineNumber)
277	dv.style.DividerLine.LineNumber = setPadding(dv.style.DividerLine.LineNumber)
278	dv.style.EqualLine.LineNumber = setPadding(dv.style.EqualLine.LineNumber)
279	dv.style.InsertLine.LineNumber = setPadding(dv.style.InsertLine.LineNumber)
280	dv.style.DeleteLine.LineNumber = setPadding(dv.style.DeleteLine.LineNumber)
281}
282
283// detectNumDigits calculates the maximum number of digits needed for before and
284// after line numbers.
285func (dv *DiffView) detectNumDigits() {
286	dv.beforeNumDigits = 0
287	dv.afterNumDigits = 0
288
289	for _, h := range dv.unified.Hunks {
290		dv.beforeNumDigits = max(dv.beforeNumDigits, len(strconv.Itoa(h.FromLine+len(h.Lines))))
291		dv.afterNumDigits = max(dv.afterNumDigits, len(strconv.Itoa(h.ToLine+len(h.Lines))))
292	}
293}
294
295func (dv *DiffView) detectTotalLines() {
296	dv.totalLines = 0
297
298	switch dv.layout {
299	case layoutUnified:
300		for _, h := range dv.unified.Hunks {
301			dv.totalLines += 1 + len(h.Lines)
302		}
303	case layoutSplit:
304		for _, h := range dv.splitHunks {
305			dv.totalLines += 1 + len(h.lines)
306		}
307	}
308}
309
310func (dv *DiffView) preventInfiniteYScroll() {
311	if dv.infiniteYScroll {
312		return
313	}
314
315	// clamp yOffset to prevent scrolling beyond the last line
316	if dv.height > 0 {
317		maxYOffset := max(0, dv.totalLines-dv.height)
318		dv.yOffset = min(dv.yOffset, maxYOffset)
319	} else {
320		// if no height limit, ensure yOffset doesn't exceed total lines
321		dv.yOffset = min(dv.yOffset, max(0, dv.totalLines-1))
322	}
323	dv.yOffset = max(0, dv.yOffset) // ensure yOffset is not negative
324}
325
326// detectCodeWidth calculates the maximum width of code lines in the diff view.
327func (dv *DiffView) detectCodeWidth() {
328	switch dv.layout {
329	case layoutUnified:
330		dv.detectUnifiedCodeWidth()
331	case layoutSplit:
332		dv.detectSplitCodeWidth()
333	}
334	dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
335}
336
337// detectUnifiedCodeWidth calculates the maximum width of code lines in a
338// unified diff.
339func (dv *DiffView) detectUnifiedCodeWidth() {
340	dv.codeWidth = 0
341
342	for _, h := range dv.unified.Hunks {
343		shownLines := ansi.StringWidth(dv.hunkLineFor(h))
344
345		for _, l := range h.Lines {
346			lineWidth := ansi.StringWidth(strings.TrimSuffix(l.Content, "\n")) + 1
347			dv.codeWidth = max(dv.codeWidth, lineWidth, shownLines)
348		}
349	}
350}
351
352// detectSplitCodeWidth calculates the maximum width of code lines in a
353// split diff.
354func (dv *DiffView) detectSplitCodeWidth() {
355	dv.codeWidth = 0
356
357	for i, h := range dv.splitHunks {
358		shownLines := ansi.StringWidth(dv.hunkLineFor(dv.unified.Hunks[i]))
359
360		for _, l := range h.lines {
361			if l.before != nil {
362				codeWidth := ansi.StringWidth(strings.TrimSuffix(l.before.Content, "\n")) + 1
363				dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
364			}
365			if l.after != nil {
366				codeWidth := ansi.StringWidth(strings.TrimSuffix(l.after.Content, "\n")) + 1
367				dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
368			}
369		}
370	}
371}
372
373// resizeCodeWidth resizes the code width to fit within the specified width.
374func (dv *DiffView) resizeCodeWidth() {
375	fullNumWidth := dv.beforeNumDigits + dv.afterNumDigits
376	fullNumWidth += lineNumPadding * 4 // left and right padding for both line numbers
377
378	switch dv.layout {
379	case layoutUnified:
380		dv.codeWidth = dv.width - fullNumWidth - leadingSymbolsSize
381	case layoutSplit:
382		remainingWidth := dv.width - fullNumWidth - leadingSymbolsSize*2
383		dv.codeWidth = remainingWidth / 2
384		dv.extraColOnAfter = isOdd(remainingWidth)
385	}
386
387	dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
388}
389
390// renderUnified renders the unified diff view as a string.
391func (dv *DiffView) renderUnified() string {
392	var b strings.Builder
393
394	fullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
395	printedLines := -dv.yOffset
396	shouldWrite := func() bool { return printedLines >= 0 }
397
398	getContent := func(in string, ls LineStyle) (content string, leadingEllipsis bool) {
399		content = strings.ReplaceAll(in, "\r\n", "\n")
400		content = strings.TrimSuffix(content, "\n")
401		content = dv.hightlightCode(content, ls.Code.GetBackground())
402		content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
403		content = ansi.Truncate(content, dv.codeWidth, "…")
404		leadingEllipsis = dv.xOffset > 0 && strings.TrimSpace(content) != ""
405		return
406	}
407
408outer:
409	for i, h := range dv.unified.Hunks {
410		if shouldWrite() {
411			ls := dv.style.DividerLine
412			if dv.lineNumbers {
413				b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
414				b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
415			}
416			content := ansi.Truncate(dv.hunkLineFor(h), dv.fullCodeWidth, "…")
417			b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(content))
418			b.WriteString("\n")
419		}
420		printedLines++
421
422		beforeLine := h.FromLine
423		afterLine := h.ToLine
424
425		for j, l := range h.Lines {
426			// print ellipis if we don't have enough space to print the rest of the diff
427			hasReachedHeight := dv.height > 0 && printedLines+1 == dv.height
428			isLastHunk := i+1 == len(dv.unified.Hunks)
429			isLastLine := j+1 == len(h.Lines)
430			if hasReachedHeight && (!isLastHunk || !isLastLine) {
431				if shouldWrite() {
432					ls := dv.lineStyleForType(l.Kind)
433					if dv.lineNumbers {
434						b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
435						b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
436					}
437					b.WriteString(fullContentStyle.Render(
438						ls.Code.Width(dv.fullCodeWidth).Render("  …"),
439					))
440					b.WriteRune('\n')
441				}
442				break outer
443			}
444
445			switch l.Kind {
446			case udiff.Equal:
447				if shouldWrite() {
448					ls := dv.style.EqualLine
449					content, leadingEllipsis := getContent(l.Content, ls)
450					if dv.lineNumbers {
451						b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
452						b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
453					}
454					b.WriteString(fullContentStyle.Render(
455						ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", "  ") + content),
456					))
457				}
458				beforeLine++
459				afterLine++
460			case udiff.Insert:
461				if shouldWrite() {
462					ls := dv.style.InsertLine
463					content, leadingEllipsis := getContent(l.Content, ls)
464					if dv.lineNumbers {
465						b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
466						b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
467					}
468					b.WriteString(fullContentStyle.Render(
469						ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
470							ls.Code.Width(dv.codeWidth).Render(content),
471					))
472				}
473				afterLine++
474			case udiff.Delete:
475				if shouldWrite() {
476					ls := dv.style.DeleteLine
477					content, leadingEllipsis := getContent(l.Content, ls)
478					if dv.lineNumbers {
479						b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
480						b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
481					}
482					b.WriteString(fullContentStyle.Render(
483						ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
484							ls.Code.Width(dv.codeWidth).Render(content),
485					))
486				}
487				beforeLine++
488			}
489			if shouldWrite() {
490				b.WriteRune('\n')
491			}
492
493			printedLines++
494		}
495	}
496
497	for printedLines < dv.height {
498		if shouldWrite() {
499			ls := dv.style.MissingLine
500			if dv.lineNumbers {
501				b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
502				b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
503			}
504			b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render("  "))
505			b.WriteRune('\n')
506		}
507		printedLines++
508	}
509
510	return b.String()
511}
512
513// renderSplit renders the split (side-by-side) diff view as a string.
514func (dv *DiffView) renderSplit() string {
515	var b strings.Builder
516
517	beforeFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
518	afterFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth + btoi(dv.extraColOnAfter))
519	printedLines := -dv.yOffset
520	shouldWrite := func() bool { return printedLines >= 0 }
521
522	getContent := func(in string, ls LineStyle) (content string, leadingEllipsis bool) {
523		content = strings.ReplaceAll(in, "\r\n", "\n")
524		content = strings.TrimSuffix(content, "\n")
525		content = dv.hightlightCode(content, ls.Code.GetBackground())
526		content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
527		content = ansi.Truncate(content, dv.codeWidth, "…")
528		leadingEllipsis = dv.xOffset > 0 && strings.TrimSpace(content) != ""
529		return
530	}
531
532outer:
533	for i, h := range dv.splitHunks {
534		if shouldWrite() {
535			ls := dv.style.DividerLine
536			if dv.lineNumbers {
537				b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
538			}
539			content := ansi.Truncate(dv.hunkLineFor(dv.unified.Hunks[i]), dv.fullCodeWidth, "…")
540			b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(content))
541			if dv.lineNumbers {
542				b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
543			}
544			b.WriteString(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
545			b.WriteRune('\n')
546		}
547		printedLines++
548
549		beforeLine := h.fromLine
550		afterLine := h.toLine
551
552		for j, l := range h.lines {
553			// print ellipis if we don't have enough space to print the rest of the diff
554			hasReachedHeight := dv.height > 0 && printedLines+1 == dv.height
555			isLastHunk := i+1 == len(dv.unified.Hunks)
556			isLastLine := j+1 == len(h.lines)
557			if hasReachedHeight && (!isLastHunk || !isLastLine) {
558				if shouldWrite() {
559					ls := dv.style.MissingLine
560					if l.before != nil {
561						ls = dv.lineStyleForType(l.before.Kind)
562					}
563					if dv.lineNumbers {
564						b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
565					}
566					b.WriteString(beforeFullContentStyle.Render(
567						ls.Code.Width(dv.fullCodeWidth).Render("  …"),
568					))
569					ls = dv.style.MissingLine
570					if l.after != nil {
571						ls = dv.lineStyleForType(l.after.Kind)
572					}
573					if dv.lineNumbers {
574						b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
575					}
576					b.WriteString(afterFullContentStyle.Render(
577						ls.Code.Width(dv.fullCodeWidth).Render("  …"),
578					))
579					b.WriteRune('\n')
580				}
581				break outer
582			}
583
584			switch {
585			case l.before == nil:
586				if shouldWrite() {
587					ls := dv.style.MissingLine
588					if dv.lineNumbers {
589						b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
590					}
591					b.WriteString(beforeFullContentStyle.Render(
592						ls.Code.Width(dv.fullCodeWidth).Render("  "),
593					))
594				}
595			case l.before.Kind == udiff.Equal:
596				if shouldWrite() {
597					ls := dv.style.EqualLine
598					content, leadingEllipsis := getContent(l.before.Content, ls)
599					if dv.lineNumbers {
600						b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
601					}
602					b.WriteString(beforeFullContentStyle.Render(
603						ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", "  ") + content),
604					))
605				}
606				beforeLine++
607			case l.before.Kind == udiff.Delete:
608				if shouldWrite() {
609					ls := dv.style.DeleteLine
610					content, leadingEllipsis := getContent(l.before.Content, ls)
611					if dv.lineNumbers {
612						b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
613					}
614					b.WriteString(beforeFullContentStyle.Render(
615						ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
616							ls.Code.Width(dv.codeWidth).Render(content),
617					))
618				}
619				beforeLine++
620			}
621
622			switch {
623			case l.after == nil:
624				if shouldWrite() {
625					ls := dv.style.MissingLine
626					if dv.lineNumbers {
627						b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
628					}
629					b.WriteString(afterFullContentStyle.Render(
630						ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render("  "),
631					))
632				}
633			case l.after.Kind == udiff.Equal:
634				if shouldWrite() {
635					ls := dv.style.EqualLine
636					content, leadingEllipsis := getContent(l.after.Content, ls)
637					if dv.lineNumbers {
638						b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
639					}
640					b.WriteString(afterFullContentStyle.Render(
641						ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(ternary(leadingEllipsis, " …", "  ") + content),
642					))
643				}
644				afterLine++
645			case l.after.Kind == udiff.Insert:
646				if shouldWrite() {
647					ls := dv.style.InsertLine
648					content, leadingEllipsis := getContent(l.after.Content, ls)
649					if dv.lineNumbers {
650						b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
651					}
652					b.WriteString(afterFullContentStyle.Render(
653						ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
654							ls.Code.Width(dv.codeWidth+btoi(dv.extraColOnAfter)).Render(content),
655					))
656				}
657				afterLine++
658			}
659
660			if shouldWrite() {
661				b.WriteRune('\n')
662			}
663
664			printedLines++
665		}
666	}
667
668	for printedLines < dv.height {
669		if shouldWrite() {
670			ls := dv.style.MissingLine
671			if dv.lineNumbers {
672				b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
673			}
674			b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(" "))
675			if dv.lineNumbers {
676				b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
677			}
678			b.WriteString(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
679			b.WriteRune('\n')
680		}
681		printedLines++
682	}
683
684	return b.String()
685}
686
687// hunkLineFor formats the header line for a hunk in the unified diff view.
688func (dv *DiffView) hunkLineFor(h *udiff.Hunk) string {
689	beforeShownLines, afterShownLines := dv.hunkShownLines(h)
690
691	return fmt.Sprintf(
692		"  @@ -%d,%d +%d,%d @@ ",
693		h.FromLine,
694		beforeShownLines,
695		h.ToLine,
696		afterShownLines,
697	)
698}
699
700// hunkShownLines calculates the number of lines shown in a hunk for both before
701// and after versions.
702func (dv *DiffView) hunkShownLines(h *udiff.Hunk) (before, after int) {
703	for _, l := range h.Lines {
704		switch l.Kind {
705		case udiff.Equal:
706			before++
707			after++
708		case udiff.Insert:
709			after++
710		case udiff.Delete:
711			before++
712		}
713	}
714	return
715}
716
717func (dv *DiffView) lineStyleForType(t udiff.OpKind) LineStyle {
718	switch t {
719	case udiff.Equal:
720		return dv.style.EqualLine
721	case udiff.Insert:
722		return dv.style.InsertLine
723	case udiff.Delete:
724		return dv.style.DeleteLine
725	default:
726		return dv.style.MissingLine
727	}
728}
729
730func (dv *DiffView) hightlightCode(source string, bgColor color.Color) string {
731	if dv.chromaStyle == nil {
732		return source
733	}
734
735	// Create cache key from content and background color
736	cacheKey := dv.createSyntaxCacheKey(source, bgColor)
737
738	// Check if we already have this highlighted
739	if cached, exists := dv.syntaxCache[cacheKey]; exists {
740		return cached
741	}
742
743	l := dv.getChromaLexer()
744	f := dv.getChromaFormatter(bgColor)
745
746	it, err := l.Tokenise(nil, source)
747	if err != nil {
748		return source
749	}
750
751	var b strings.Builder
752	if err := f.Format(&b, dv.chromaStyle, it); err != nil {
753		return source
754	}
755
756	result := b.String()
757
758	// Cache the result for future use
759	dv.syntaxCache[cacheKey] = result
760
761	return result
762}
763
764// createSyntaxCacheKey creates a cache key from source content and background color.
765// We use a simple hash to keep memory usage reasonable.
766func (dv *DiffView) createSyntaxCacheKey(source string, bgColor color.Color) string {
767	// Convert color to string representation
768	r, g, b, a := bgColor.RGBA()
769	colorStr := fmt.Sprintf("%d,%d,%d,%d", r, g, b, a)
770
771	// Create a hash of the content + color to use as cache key
772	h := xxh3.New()
773	h.Write([]byte(source))
774	h.Write([]byte(colorStr))
775	return fmt.Sprintf("%x", h.Sum(nil))
776}
777
778func (dv *DiffView) getChromaLexer() chroma.Lexer {
779	if dv.cachedLexer != nil {
780		return dv.cachedLexer
781	}
782
783	l := lexers.Match(dv.before.path)
784	if l == nil {
785		l = lexers.Analyse(dv.before.content)
786	}
787	if l == nil {
788		l = lexers.Fallback
789	}
790	dv.cachedLexer = chroma.Coalesce(l)
791	return dv.cachedLexer
792}
793
794func (dv *DiffView) getChromaFormatter(bgColor color.Color) chroma.Formatter {
795	return chromaFormatter{
796		bgColor: bgColor,
797	}
798}