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