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}