diffview.go

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