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
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	for printedLines < dv.height {
504		if shouldWrite() {
505			ls := dv.style.MissingLine
506			if dv.lineNumbers {
507				b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
508				b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
509			}
510			b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render("  "))
511			b.WriteRune('\n')
512		}
513		printedLines++
514	}
515
516	return b.String()
517}
518
519// renderSplit renders the split (side-by-side) diff view as a string.
520func (dv *DiffView) renderSplit() string {
521	var b strings.Builder
522
523	beforeFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
524	afterFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth + btoi(dv.extraColOnAfter))
525	printedLines := -dv.yOffset
526	shouldWrite := func() bool { return printedLines >= 0 }
527
528	getContent := func(in string, ls LineStyle) (content string, leadingEllipsis bool) {
529		content = strings.TrimSuffix(in, "\n")
530		content = dv.hightlightCode(content, ls.Code.GetBackground())
531		content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
532		content = ansi.Truncate(content, dv.codeWidth, "…")
533		leadingEllipsis = dv.xOffset > 0 && strings.TrimSpace(content) != ""
534		return
535	}
536
537outer:
538	for i, h := range dv.splitHunks {
539		if shouldWrite() {
540			ls := dv.style.DividerLine
541			if dv.lineNumbers {
542				b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
543			}
544			content := ansi.Truncate(dv.hunkLineFor(dv.unified.Hunks[i]), dv.fullCodeWidth, "…")
545			b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(content))
546			if dv.lineNumbers {
547				b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
548			}
549			b.WriteString(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
550			b.WriteRune('\n')
551		}
552		printedLines++
553
554		beforeLine := h.fromLine
555		afterLine := h.toLine
556
557		for j, l := range h.lines {
558			// print ellipis if we don't have enough space to print the rest of the diff
559			hasReachedHeight := dv.height > 0 && printedLines+1 == dv.height
560			isLastHunk := i+1 == len(dv.unified.Hunks)
561			isLastLine := j+1 == len(h.lines)
562			if hasReachedHeight && (!isLastHunk || !isLastLine) {
563				if shouldWrite() {
564					ls := dv.style.MissingLine
565					if l.before != nil {
566						ls = dv.lineStyleForType(l.before.Kind)
567					}
568					if dv.lineNumbers {
569						b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
570					}
571					b.WriteString(beforeFullContentStyle.Render(
572						ls.Code.Width(dv.fullCodeWidth).Render("  …"),
573					))
574					ls = dv.style.MissingLine
575					if l.after != nil {
576						ls = dv.lineStyleForType(l.after.Kind)
577					}
578					if dv.lineNumbers {
579						b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
580					}
581					b.WriteString(afterFullContentStyle.Render(
582						ls.Code.Width(dv.fullCodeWidth).Render("  …"),
583					))
584					b.WriteRune('\n')
585				}
586				break outer
587			}
588
589			switch {
590			case l.before == nil:
591				if shouldWrite() {
592					ls := dv.style.MissingLine
593					if dv.lineNumbers {
594						b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
595					}
596					b.WriteString(beforeFullContentStyle.Render(
597						ls.Code.Width(dv.fullCodeWidth).Render("  "),
598					))
599				}
600			case l.before.Kind == udiff.Equal:
601				if shouldWrite() {
602					ls := dv.style.EqualLine
603					content, leadingEllipsis := getContent(l.before.Content, ls)
604					if dv.lineNumbers {
605						b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
606					}
607					b.WriteString(beforeFullContentStyle.Render(
608						ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", "  ") + content),
609					))
610				}
611				beforeLine++
612			case l.before.Kind == udiff.Delete:
613				if shouldWrite() {
614					ls := dv.style.DeleteLine
615					content, leadingEllipsis := getContent(l.before.Content, ls)
616					if dv.lineNumbers {
617						b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
618					}
619					b.WriteString(beforeFullContentStyle.Render(
620						ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
621							ls.Code.Width(dv.codeWidth).Render(content),
622					))
623				}
624				beforeLine++
625			}
626
627			switch {
628			case l.after == nil:
629				if shouldWrite() {
630					ls := dv.style.MissingLine
631					if dv.lineNumbers {
632						b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
633					}
634					b.WriteString(afterFullContentStyle.Render(
635						ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render("  "),
636					))
637				}
638			case l.after.Kind == udiff.Equal:
639				if shouldWrite() {
640					ls := dv.style.EqualLine
641					content, leadingEllipsis := getContent(l.after.Content, ls)
642					if dv.lineNumbers {
643						b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
644					}
645					b.WriteString(afterFullContentStyle.Render(
646						ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(ternary(leadingEllipsis, " …", "  ") + content),
647					))
648				}
649				afterLine++
650			case l.after.Kind == udiff.Insert:
651				if shouldWrite() {
652					ls := dv.style.InsertLine
653					content, leadingEllipsis := getContent(l.after.Content, ls)
654					if dv.lineNumbers {
655						b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
656					}
657					b.WriteString(afterFullContentStyle.Render(
658						ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
659							ls.Code.Width(dv.codeWidth+btoi(dv.extraColOnAfter)).Render(content),
660					))
661				}
662				afterLine++
663			}
664
665			if shouldWrite() {
666				b.WriteRune('\n')
667			}
668
669			printedLines++
670		}
671	}
672
673	for printedLines < dv.height {
674		if shouldWrite() {
675			ls := dv.style.MissingLine
676			if dv.lineNumbers {
677				b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
678			}
679			b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(" "))
680			if dv.lineNumbers {
681				b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
682			}
683			b.WriteString(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
684			b.WriteRune('\n')
685		}
686		printedLines++
687	}
688
689	return b.String()
690}
691
692// hunkLineFor formats the header line for a hunk in the unified diff view.
693func (dv *DiffView) hunkLineFor(h *udiff.Hunk) string {
694	beforeShownLines, afterShownLines := dv.hunkShownLines(h)
695
696	return fmt.Sprintf(
697		"  @@ -%d,%d +%d,%d @@ ",
698		h.FromLine,
699		beforeShownLines,
700		h.ToLine,
701		afterShownLines,
702	)
703}
704
705// hunkShownLines calculates the number of lines shown in a hunk for both before
706// and after versions.
707func (dv *DiffView) hunkShownLines(h *udiff.Hunk) (before, after int) {
708	for _, l := range h.Lines {
709		switch l.Kind {
710		case udiff.Equal:
711			before++
712			after++
713		case udiff.Insert:
714			after++
715		case udiff.Delete:
716			before++
717		}
718	}
719	return
720}
721
722func (dv *DiffView) lineStyleForType(t udiff.OpKind) LineStyle {
723	switch t {
724	case udiff.Equal:
725		return dv.style.EqualLine
726	case udiff.Insert:
727		return dv.style.InsertLine
728	case udiff.Delete:
729		return dv.style.DeleteLine
730	default:
731		return dv.style.MissingLine
732	}
733}
734
735func (dv *DiffView) hightlightCode(source string, bgColor color.Color) string {
736	if dv.chromaStyle == nil {
737		return source
738	}
739
740	// Create cache key from content and background color
741	cacheKey := dv.createSyntaxCacheKey(source, bgColor)
742
743	// Check if we already have this highlighted
744	if cached, exists := dv.syntaxCache[cacheKey]; exists {
745		return cached
746	}
747
748	l := dv.getChromaLexer()
749	f := dv.getChromaFormatter(bgColor)
750
751	it, err := l.Tokenise(nil, source)
752	if err != nil {
753		return source
754	}
755
756	var b strings.Builder
757	if err := f.Format(&b, dv.chromaStyle, it); err != nil {
758		return source
759	}
760
761	result := b.String()
762
763	// Cache the result for future use
764	dv.syntaxCache[cacheKey] = result
765
766	return result
767}
768
769// createSyntaxCacheKey creates a cache key from source content and background color.
770// We use a simple hash to keep memory usage reasonable.
771func (dv *DiffView) createSyntaxCacheKey(source string, bgColor color.Color) string {
772	// Convert color to string representation
773	r, g, b, a := bgColor.RGBA()
774	colorStr := fmt.Sprintf("%d,%d,%d,%d", r, g, b, a)
775
776	// Create a hash of the content + color to use as cache key
777	h := xxh3.New()
778	h.Write([]byte(source))
779	h.Write([]byte(colorStr))
780	return fmt.Sprintf("%x", h.Sum(nil))
781}
782
783func (dv *DiffView) getChromaLexer() chroma.Lexer {
784	if dv.cachedLexer != nil {
785		return dv.cachedLexer
786	}
787
788	l := lexers.Match(dv.before.path)
789	if l == nil {
790		l = lexers.Analyse(dv.before.content)
791	}
792	if l == nil {
793		l = lexers.Fallback
794	}
795	dv.cachedLexer = chroma.Coalesce(l)
796	return dv.cachedLexer
797}
798
799func (dv *DiffView) getChromaFormatter(bgColor color.Color) chroma.Formatter {
800	return chromaFormatter{
801		bgColor: bgColor,
802	}
803}