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.normalizeLineEndings()
197	dv.replaceTabs()
198	if err := dv.computeDiff(); err != nil {
199		return err.Error()
200	}
201	dv.convertDiffToSplit()
202	dv.adjustStyles()
203	dv.detectNumDigits()
204	dv.detectTotalLines()
205	dv.preventInfiniteYScroll()
206
207	if dv.width <= 0 {
208		dv.detectCodeWidth()
209	} else {
210		dv.resizeCodeWidth()
211	}
212
213	style := lipgloss.NewStyle()
214	if dv.width > 0 {
215		style = style.MaxWidth(dv.width)
216	}
217	if dv.height > 0 {
218		style = style.MaxHeight(dv.height)
219	}
220
221	switch dv.layout {
222	case layoutUnified:
223		return style.Render(strings.TrimSuffix(dv.renderUnified(), "\n"))
224	case layoutSplit:
225		return style.Render(strings.TrimSuffix(dv.renderSplit(), "\n"))
226	default:
227		panic("unknown diffview layout")
228	}
229}
230
231// normalizeLineEndings ensures the file contents use Unix-style line endings.
232func (dv *DiffView) normalizeLineEndings() {
233	dv.before.content = strings.ReplaceAll(dv.before.content, "\r\n", "\n")
234	dv.after.content = strings.ReplaceAll(dv.after.content, "\r\n", "\n")
235}
236
237// replaceTabs replaces tabs in the before and after file contents with spaces
238// according to the specified tab width.
239func (dv *DiffView) replaceTabs() {
240	spaces := strings.Repeat(" ", dv.tabWidth)
241	dv.before.content = strings.ReplaceAll(dv.before.content, "\t", spaces)
242	dv.after.content = strings.ReplaceAll(dv.after.content, "\t", spaces)
243}
244
245// computeDiff computes the differences between the "before" and "after" files.
246func (dv *DiffView) computeDiff() error {
247	if dv.isComputed {
248		return dv.err
249	}
250	dv.isComputed = true
251	dv.edits = udiff.Strings(
252		dv.before.content,
253		dv.after.content,
254	)
255	dv.unified, dv.err = udiff.ToUnifiedDiff(
256		dv.before.path,
257		dv.after.path,
258		dv.before.content,
259		dv.edits,
260		dv.contextLines,
261	)
262	return dv.err
263}
264
265// convertDiffToSplit converts the unified diff to a split diff if the layout is
266// set to split.
267func (dv *DiffView) convertDiffToSplit() {
268	if dv.layout != layoutSplit {
269		return
270	}
271
272	dv.splitHunks = make([]splitHunk, len(dv.unified.Hunks))
273	for i, h := range dv.unified.Hunks {
274		dv.splitHunks[i] = hunkToSplit(h)
275	}
276}
277
278// adjustStyles adjusts adds padding and alignment to the styles.
279func (dv *DiffView) adjustStyles() {
280	setPadding := func(s lipgloss.Style) lipgloss.Style {
281		return s.Padding(0, lineNumPadding).Align(lipgloss.Right)
282	}
283	dv.style.MissingLine.LineNumber = setPadding(dv.style.MissingLine.LineNumber)
284	dv.style.DividerLine.LineNumber = setPadding(dv.style.DividerLine.LineNumber)
285	dv.style.EqualLine.LineNumber = setPadding(dv.style.EqualLine.LineNumber)
286	dv.style.InsertLine.LineNumber = setPadding(dv.style.InsertLine.LineNumber)
287	dv.style.DeleteLine.LineNumber = setPadding(dv.style.DeleteLine.LineNumber)
288}
289
290// detectNumDigits calculates the maximum number of digits needed for before and
291// after line numbers.
292func (dv *DiffView) detectNumDigits() {
293	dv.beforeNumDigits = 0
294	dv.afterNumDigits = 0
295
296	for _, h := range dv.unified.Hunks {
297		dv.beforeNumDigits = max(dv.beforeNumDigits, len(strconv.Itoa(h.FromLine+len(h.Lines))))
298		dv.afterNumDigits = max(dv.afterNumDigits, len(strconv.Itoa(h.ToLine+len(h.Lines))))
299	}
300}
301
302func (dv *DiffView) detectTotalLines() {
303	dv.totalLines = 0
304
305	switch dv.layout {
306	case layoutUnified:
307		for _, h := range dv.unified.Hunks {
308			dv.totalLines += 1 + len(h.Lines)
309		}
310	case layoutSplit:
311		for _, h := range dv.splitHunks {
312			dv.totalLines += 1 + len(h.lines)
313		}
314	}
315}
316
317func (dv *DiffView) preventInfiniteYScroll() {
318	if dv.infiniteYScroll {
319		return
320	}
321
322	// clamp yOffset to prevent scrolling beyond the last line
323	if dv.height > 0 {
324		maxYOffset := max(0, dv.totalLines-dv.height)
325		dv.yOffset = min(dv.yOffset, maxYOffset)
326	} else {
327		// if no height limit, ensure yOffset doesn't exceed total lines
328		dv.yOffset = min(dv.yOffset, max(0, dv.totalLines-1))
329	}
330	dv.yOffset = max(0, dv.yOffset) // ensure yOffset is not negative
331}
332
333// detectCodeWidth calculates the maximum width of code lines in the diff view.
334func (dv *DiffView) detectCodeWidth() {
335	switch dv.layout {
336	case layoutUnified:
337		dv.detectUnifiedCodeWidth()
338	case layoutSplit:
339		dv.detectSplitCodeWidth()
340	}
341	dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
342}
343
344// detectUnifiedCodeWidth calculates the maximum width of code lines in a
345// unified diff.
346func (dv *DiffView) detectUnifiedCodeWidth() {
347	dv.codeWidth = 0
348
349	for _, h := range dv.unified.Hunks {
350		shownLines := ansi.StringWidth(dv.hunkLineFor(h))
351
352		for _, l := range h.Lines {
353			lineWidth := ansi.StringWidth(strings.TrimSuffix(l.Content, "\n")) + 1
354			dv.codeWidth = max(dv.codeWidth, lineWidth, shownLines)
355		}
356	}
357}
358
359// detectSplitCodeWidth calculates the maximum width of code lines in a
360// split diff.
361func (dv *DiffView) detectSplitCodeWidth() {
362	dv.codeWidth = 0
363
364	for i, h := range dv.splitHunks {
365		shownLines := ansi.StringWidth(dv.hunkLineFor(dv.unified.Hunks[i]))
366
367		for _, l := range h.lines {
368			if l.before != nil {
369				codeWidth := ansi.StringWidth(strings.TrimSuffix(l.before.Content, "\n")) + 1
370				dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
371			}
372			if l.after != nil {
373				codeWidth := ansi.StringWidth(strings.TrimSuffix(l.after.Content, "\n")) + 1
374				dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
375			}
376		}
377	}
378}
379
380// resizeCodeWidth resizes the code width to fit within the specified width.
381func (dv *DiffView) resizeCodeWidth() {
382	fullNumWidth := dv.beforeNumDigits + dv.afterNumDigits
383	fullNumWidth += lineNumPadding * 4 // left and right padding for both line numbers
384
385	switch dv.layout {
386	case layoutUnified:
387		dv.codeWidth = dv.width - fullNumWidth - leadingSymbolsSize
388	case layoutSplit:
389		remainingWidth := dv.width - fullNumWidth - leadingSymbolsSize*2
390		dv.codeWidth = remainingWidth / 2
391		dv.extraColOnAfter = isOdd(remainingWidth)
392	}
393
394	dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
395}
396
397// renderUnified renders the unified diff view as a string.
398func (dv *DiffView) renderUnified() string {
399	var b strings.Builder
400
401	fullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
402	printedLines := -dv.yOffset
403	shouldWrite := func() bool { return printedLines >= 0 }
404
405	getContent := func(in string, ls LineStyle) (content string, leadingEllipsis bool) {
406		content = strings.TrimSuffix(in, "\n")
407		content = dv.hightlightCode(content, ls.Code.GetBackground())
408		content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
409		content = ansi.Truncate(content, dv.codeWidth, "…")
410		leadingEllipsis = dv.xOffset > 0 && strings.TrimSpace(content) != ""
411		return content, leadingEllipsis
412	}
413
414outer:
415	for i, h := range dv.unified.Hunks {
416		if shouldWrite() {
417			ls := dv.style.DividerLine
418			if dv.lineNumbers {
419				b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
420				b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
421			}
422			content := ansi.Truncate(dv.hunkLineFor(h), dv.fullCodeWidth, "…")
423			b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(content))
424			b.WriteString("\n")
425		}
426		printedLines++
427
428		beforeLine := h.FromLine
429		afterLine := h.ToLine
430
431		for j, l := range h.Lines {
432			// print ellipis if we don't have enough space to print the rest of the diff
433			hasReachedHeight := dv.height > 0 && printedLines+1 == dv.height
434			isLastHunk := i+1 == len(dv.unified.Hunks)
435			isLastLine := j+1 == len(h.Lines)
436			if hasReachedHeight && (!isLastHunk || !isLastLine) {
437				if shouldWrite() {
438					ls := dv.lineStyleForType(l.Kind)
439					if dv.lineNumbers {
440						b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
441						b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
442					}
443					b.WriteString(fullContentStyle.Render(
444						ls.Code.Width(dv.fullCodeWidth).Render("  …"),
445					))
446					b.WriteRune('\n')
447				}
448				break outer
449			}
450
451			switch l.Kind {
452			case udiff.Equal:
453				if shouldWrite() {
454					ls := dv.style.EqualLine
455					content, leadingEllipsis := getContent(l.Content, ls)
456					if dv.lineNumbers {
457						b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
458						b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
459					}
460					b.WriteString(fullContentStyle.Render(
461						ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", "  ") + content),
462					))
463				}
464				beforeLine++
465				afterLine++
466			case udiff.Insert:
467				if shouldWrite() {
468					ls := dv.style.InsertLine
469					content, leadingEllipsis := getContent(l.Content, ls)
470					if dv.lineNumbers {
471						b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
472						b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
473					}
474					b.WriteString(fullContentStyle.Render(
475						ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
476							ls.Code.Width(dv.codeWidth).Render(content),
477					))
478				}
479				afterLine++
480			case udiff.Delete:
481				if shouldWrite() {
482					ls := dv.style.DeleteLine
483					content, leadingEllipsis := getContent(l.Content, ls)
484					if dv.lineNumbers {
485						b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
486						b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
487					}
488					b.WriteString(fullContentStyle.Render(
489						ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
490							ls.Code.Width(dv.codeWidth).Render(content),
491					))
492				}
493				beforeLine++
494			}
495			if shouldWrite() {
496				b.WriteRune('\n')
497			}
498
499			printedLines++
500		}
501	}
502
503	return b.String()
504}
505
506// renderSplit renders the split (side-by-side) diff view as a string.
507func (dv *DiffView) renderSplit() string {
508	var b strings.Builder
509
510	beforeFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
511	afterFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth + btoi(dv.extraColOnAfter))
512	printedLines := -dv.yOffset
513	shouldWrite := func() bool { return printedLines >= 0 }
514
515	getContent := func(in string, ls LineStyle) (content string, leadingEllipsis bool) {
516		content = strings.TrimSuffix(in, "\n")
517		content = dv.hightlightCode(content, ls.Code.GetBackground())
518		content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
519		content = ansi.Truncate(content, dv.codeWidth, "…")
520		leadingEllipsis = dv.xOffset > 0 && strings.TrimSpace(content) != ""
521		return content, leadingEllipsis
522	}
523
524outer:
525	for i, h := range dv.splitHunks {
526		if shouldWrite() {
527			ls := dv.style.DividerLine
528			if dv.lineNumbers {
529				b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
530			}
531			content := ansi.Truncate(dv.hunkLineFor(dv.unified.Hunks[i]), dv.fullCodeWidth, "…")
532			b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(content))
533			if dv.lineNumbers {
534				b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
535			}
536			b.WriteString(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
537			b.WriteRune('\n')
538		}
539		printedLines++
540
541		beforeLine := h.fromLine
542		afterLine := h.toLine
543
544		for j, l := range h.lines {
545			// print ellipis if we don't have enough space to print the rest of the diff
546			hasReachedHeight := dv.height > 0 && printedLines+1 == dv.height
547			isLastHunk := i+1 == len(dv.unified.Hunks)
548			isLastLine := j+1 == len(h.lines)
549			if hasReachedHeight && (!isLastHunk || !isLastLine) {
550				if shouldWrite() {
551					ls := dv.style.MissingLine
552					if l.before != nil {
553						ls = dv.lineStyleForType(l.before.Kind)
554					}
555					if dv.lineNumbers {
556						b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
557					}
558					b.WriteString(beforeFullContentStyle.Render(
559						ls.Code.Width(dv.fullCodeWidth).Render("  …"),
560					))
561					ls = dv.style.MissingLine
562					if l.after != nil {
563						ls = dv.lineStyleForType(l.after.Kind)
564					}
565					if dv.lineNumbers {
566						b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
567					}
568					b.WriteString(afterFullContentStyle.Render(
569						ls.Code.Width(dv.fullCodeWidth).Render("  …"),
570					))
571					b.WriteRune('\n')
572				}
573				break outer
574			}
575
576			switch {
577			case l.before == nil:
578				if shouldWrite() {
579					ls := dv.style.MissingLine
580					if dv.lineNumbers {
581						b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
582					}
583					b.WriteString(beforeFullContentStyle.Render(
584						ls.Code.Width(dv.fullCodeWidth).Render("  "),
585					))
586				}
587			case l.before.Kind == udiff.Equal:
588				if shouldWrite() {
589					ls := dv.style.EqualLine
590					content, leadingEllipsis := getContent(l.before.Content, ls)
591					if dv.lineNumbers {
592						b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
593					}
594					b.WriteString(beforeFullContentStyle.Render(
595						ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", "  ") + content),
596					))
597				}
598				beforeLine++
599			case l.before.Kind == udiff.Delete:
600				if shouldWrite() {
601					ls := dv.style.DeleteLine
602					content, leadingEllipsis := getContent(l.before.Content, ls)
603					if dv.lineNumbers {
604						b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
605					}
606					b.WriteString(beforeFullContentStyle.Render(
607						ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
608							ls.Code.Width(dv.codeWidth).Render(content),
609					))
610				}
611				beforeLine++
612			}
613
614			switch {
615			case l.after == nil:
616				if shouldWrite() {
617					ls := dv.style.MissingLine
618					if dv.lineNumbers {
619						b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
620					}
621					b.WriteString(afterFullContentStyle.Render(
622						ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render("  "),
623					))
624				}
625			case l.after.Kind == udiff.Equal:
626				if shouldWrite() {
627					ls := dv.style.EqualLine
628					content, leadingEllipsis := getContent(l.after.Content, ls)
629					if dv.lineNumbers {
630						b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
631					}
632					b.WriteString(afterFullContentStyle.Render(
633						ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(ternary(leadingEllipsis, " …", "  ") + content),
634					))
635				}
636				afterLine++
637			case l.after.Kind == udiff.Insert:
638				if shouldWrite() {
639					ls := dv.style.InsertLine
640					content, leadingEllipsis := getContent(l.after.Content, ls)
641					if dv.lineNumbers {
642						b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
643					}
644					b.WriteString(afterFullContentStyle.Render(
645						ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
646							ls.Code.Width(dv.codeWidth+btoi(dv.extraColOnAfter)).Render(content),
647					))
648				}
649				afterLine++
650			}
651
652			if shouldWrite() {
653				b.WriteRune('\n')
654			}
655
656			printedLines++
657		}
658	}
659
660	return b.String()
661}
662
663// hunkLineFor formats the header line for a hunk in the unified diff view.
664func (dv *DiffView) hunkLineFor(h *udiff.Hunk) string {
665	beforeShownLines, afterShownLines := dv.hunkShownLines(h)
666
667	return fmt.Sprintf(
668		"  @@ -%d,%d +%d,%d @@ ",
669		h.FromLine,
670		beforeShownLines,
671		h.ToLine,
672		afterShownLines,
673	)
674}
675
676// hunkShownLines calculates the number of lines shown in a hunk for both before
677// and after versions.
678func (dv *DiffView) hunkShownLines(h *udiff.Hunk) (before, after int) {
679	for _, l := range h.Lines {
680		switch l.Kind {
681		case udiff.Equal:
682			before++
683			after++
684		case udiff.Insert:
685			after++
686		case udiff.Delete:
687			before++
688		}
689	}
690	return before, after
691}
692
693func (dv *DiffView) lineStyleForType(t udiff.OpKind) LineStyle {
694	switch t {
695	case udiff.Equal:
696		return dv.style.EqualLine
697	case udiff.Insert:
698		return dv.style.InsertLine
699	case udiff.Delete:
700		return dv.style.DeleteLine
701	default:
702		return dv.style.MissingLine
703	}
704}
705
706func (dv *DiffView) hightlightCode(source string, bgColor color.Color) string {
707	if dv.chromaStyle == nil {
708		return source
709	}
710
711	// Create cache key from content and background color
712	cacheKey := dv.createSyntaxCacheKey(source, bgColor)
713
714	// Check if we already have this highlighted
715	if cached, exists := dv.syntaxCache[cacheKey]; exists {
716		return cached
717	}
718
719	l := dv.getChromaLexer()
720	f := dv.getChromaFormatter(bgColor)
721
722	it, err := l.Tokenise(nil, source)
723	if err != nil {
724		return source
725	}
726
727	var b strings.Builder
728	if err := f.Format(&b, dv.chromaStyle, it); err != nil {
729		return source
730	}
731
732	result := b.String()
733
734	// Cache the result for future use
735	dv.syntaxCache[cacheKey] = result
736
737	return result
738}
739
740// createSyntaxCacheKey creates a cache key from source content and background color.
741// We use a simple hash to keep memory usage reasonable.
742func (dv *DiffView) createSyntaxCacheKey(source string, bgColor color.Color) string {
743	// Convert color to string representation
744	r, g, b, a := bgColor.RGBA()
745	colorStr := fmt.Sprintf("%d,%d,%d,%d", r, g, b, a)
746
747	// Create a hash of the content + color to use as cache key
748	h := xxh3.New()
749	h.Write([]byte(source))
750	h.Write([]byte(colorStr))
751	return fmt.Sprintf("%x", h.Sum(nil))
752}
753
754func (dv *DiffView) getChromaLexer() chroma.Lexer {
755	if dv.cachedLexer != nil {
756		return dv.cachedLexer
757	}
758
759	l := lexers.Match(dv.before.path)
760	if l == nil {
761		l = lexers.Analyse(dv.before.content)
762	}
763	if l == nil {
764		l = lexers.Fallback
765	}
766	dv.cachedLexer = chroma.Coalesce(l)
767	return dv.cachedLexer
768}
769
770func (dv *DiffView) getChromaFormatter(bgColor color.Color) chroma.Formatter {
771	return chromaFormatter{
772		bgColor: bgColor,
773	}
774}