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}