1package diffview
2
3import (
4 "crypto/sha256"
5 "fmt"
6 "image/color"
7 "strconv"
8 "strings"
9
10 "github.com/alecthomas/chroma/v2"
11 "github.com/alecthomas/chroma/v2/lexers"
12 "github.com/aymanbagabas/go-udiff"
13 "github.com/charmbracelet/lipgloss/v2"
14 "github.com/charmbracelet/x/ansi"
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.replaceTabs()
197 if err := dv.computeDiff(); err != nil {
198 return err.Error()
199 }
200 dv.convertDiffToSplit()
201 dv.adjustStyles()
202 dv.detectNumDigits()
203 dv.detectTotalLines()
204 dv.preventInfiniteYScroll()
205
206 if dv.width <= 0 {
207 dv.detectCodeWidth()
208 } else {
209 dv.resizeCodeWidth()
210 }
211
212 style := lipgloss.NewStyle()
213 if dv.width > 0 {
214 style = style.MaxWidth(dv.width)
215 }
216 if dv.height > 0 {
217 style = style.MaxHeight(dv.height)
218 }
219
220 switch dv.layout {
221 case layoutUnified:
222 return style.Render(strings.TrimSuffix(dv.renderUnified(), "\n"))
223 case layoutSplit:
224 return style.Render(strings.TrimSuffix(dv.renderSplit(), "\n"))
225 default:
226 panic("unknown diffview layout")
227 }
228}
229
230// replaceTabs replaces tabs in the before and after file contents with spaces
231// according to the specified tab width.
232func (dv *DiffView) replaceTabs() {
233 spaces := strings.Repeat(" ", dv.tabWidth)
234 dv.before.content = strings.ReplaceAll(dv.before.content, "\t", spaces)
235 dv.after.content = strings.ReplaceAll(dv.after.content, "\t", spaces)
236}
237
238// computeDiff computes the differences between the "before" and "after" files.
239func (dv *DiffView) computeDiff() error {
240 if dv.isComputed {
241 return dv.err
242 }
243 dv.isComputed = true
244 dv.edits = udiff.Strings(
245 dv.before.content,
246 dv.after.content,
247 )
248 dv.unified, dv.err = udiff.ToUnifiedDiff(
249 dv.before.path,
250 dv.after.path,
251 dv.before.content,
252 dv.edits,
253 dv.contextLines,
254 )
255 return dv.err
256}
257
258// convertDiffToSplit converts the unified diff to a split diff if the layout is
259// set to split.
260func (dv *DiffView) convertDiffToSplit() {
261 if dv.layout != layoutSplit {
262 return
263 }
264
265 dv.splitHunks = make([]splitHunk, len(dv.unified.Hunks))
266 for i, h := range dv.unified.Hunks {
267 dv.splitHunks[i] = hunkToSplit(h)
268 }
269}
270
271// adjustStyles adjusts adds padding and alignment to the styles.
272func (dv *DiffView) adjustStyles() {
273 setPadding := func(s lipgloss.Style) lipgloss.Style {
274 return s.Padding(0, lineNumPadding).Align(lipgloss.Right)
275 }
276 dv.style.MissingLine.LineNumber = setPadding(dv.style.MissingLine.LineNumber)
277 dv.style.DividerLine.LineNumber = setPadding(dv.style.DividerLine.LineNumber)
278 dv.style.EqualLine.LineNumber = setPadding(dv.style.EqualLine.LineNumber)
279 dv.style.InsertLine.LineNumber = setPadding(dv.style.InsertLine.LineNumber)
280 dv.style.DeleteLine.LineNumber = setPadding(dv.style.DeleteLine.LineNumber)
281}
282
283// detectNumDigits calculates the maximum number of digits needed for before and
284// after line numbers.
285func (dv *DiffView) detectNumDigits() {
286 dv.beforeNumDigits = 0
287 dv.afterNumDigits = 0
288
289 for _, h := range dv.unified.Hunks {
290 dv.beforeNumDigits = max(dv.beforeNumDigits, len(strconv.Itoa(h.FromLine+len(h.Lines))))
291 dv.afterNumDigits = max(dv.afterNumDigits, len(strconv.Itoa(h.ToLine+len(h.Lines))))
292 }
293}
294
295func (dv *DiffView) detectTotalLines() {
296 dv.totalLines = 0
297
298 switch dv.layout {
299 case layoutUnified:
300 for _, h := range dv.unified.Hunks {
301 dv.totalLines += 1 + len(h.Lines)
302 }
303 case layoutSplit:
304 for _, h := range dv.splitHunks {
305 dv.totalLines += 1 + len(h.lines)
306 }
307 }
308}
309
310func (dv *DiffView) preventInfiniteYScroll() {
311 if dv.infiniteYScroll {
312 return
313 }
314
315 // clamp yOffset to prevent scrolling beyond the last line
316 if dv.height > 0 {
317 maxYOffset := max(0, dv.totalLines-dv.height)
318 dv.yOffset = min(dv.yOffset, maxYOffset)
319 } else {
320 // if no height limit, ensure yOffset doesn't exceed total lines
321 dv.yOffset = min(dv.yOffset, max(0, dv.totalLines-1))
322 }
323 dv.yOffset = max(0, dv.yOffset) // ensure yOffset is not negative
324}
325
326// detectCodeWidth calculates the maximum width of code lines in the diff view.
327func (dv *DiffView) detectCodeWidth() {
328 switch dv.layout {
329 case layoutUnified:
330 dv.detectUnifiedCodeWidth()
331 case layoutSplit:
332 dv.detectSplitCodeWidth()
333 }
334 dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
335}
336
337// detectUnifiedCodeWidth calculates the maximum width of code lines in a
338// unified diff.
339func (dv *DiffView) detectUnifiedCodeWidth() {
340 dv.codeWidth = 0
341
342 for _, h := range dv.unified.Hunks {
343 shownLines := ansi.StringWidth(dv.hunkLineFor(h))
344
345 for _, l := range h.Lines {
346 lineWidth := ansi.StringWidth(strings.TrimSuffix(l.Content, "\n")) + 1
347 dv.codeWidth = max(dv.codeWidth, lineWidth, shownLines)
348 }
349 }
350}
351
352// detectSplitCodeWidth calculates the maximum width of code lines in a
353// split diff.
354func (dv *DiffView) detectSplitCodeWidth() {
355 dv.codeWidth = 0
356
357 for i, h := range dv.splitHunks {
358 shownLines := ansi.StringWidth(dv.hunkLineFor(dv.unified.Hunks[i]))
359
360 for _, l := range h.lines {
361 if l.before != nil {
362 codeWidth := ansi.StringWidth(strings.TrimSuffix(l.before.Content, "\n")) + 1
363 dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
364 }
365 if l.after != nil {
366 codeWidth := ansi.StringWidth(strings.TrimSuffix(l.after.Content, "\n")) + 1
367 dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
368 }
369 }
370 }
371}
372
373// resizeCodeWidth resizes the code width to fit within the specified width.
374func (dv *DiffView) resizeCodeWidth() {
375 fullNumWidth := dv.beforeNumDigits + dv.afterNumDigits
376 fullNumWidth += lineNumPadding * 4 // left and right padding for both line numbers
377
378 switch dv.layout {
379 case layoutUnified:
380 dv.codeWidth = dv.width - fullNumWidth - leadingSymbolsSize
381 case layoutSplit:
382 remainingWidth := dv.width - fullNumWidth - leadingSymbolsSize*2
383 dv.codeWidth = remainingWidth / 2
384 dv.extraColOnAfter = isOdd(remainingWidth)
385 }
386
387 dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
388}
389
390// renderUnified renders the unified diff view as a string.
391func (dv *DiffView) renderUnified() string {
392 var b strings.Builder
393
394 fullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
395 printedLines := -dv.yOffset
396 shouldWrite := func() bool { return printedLines >= 0 }
397
398 getContent := func(in string, ls LineStyle) (content string, leadingEllipsis bool) {
399 content = strings.ReplaceAll(in, "\r\n", "\n")
400 content = strings.TrimSuffix(content, "\n")
401 content = dv.hightlightCode(content, ls.Code.GetBackground())
402 content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
403 content = ansi.Truncate(content, dv.codeWidth, "…")
404 leadingEllipsis = dv.xOffset > 0 && strings.TrimSpace(content) != ""
405 return
406 }
407
408outer:
409 for i, h := range dv.unified.Hunks {
410 if shouldWrite() {
411 ls := dv.style.DividerLine
412 if dv.lineNumbers {
413 b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
414 b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
415 }
416 content := ansi.Truncate(dv.hunkLineFor(h), dv.fullCodeWidth, "…")
417 b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(content))
418 b.WriteString("\n")
419 }
420 printedLines++
421
422 beforeLine := h.FromLine
423 afterLine := h.ToLine
424
425 for j, l := range h.Lines {
426 // print ellipis if we don't have enough space to print the rest of the diff
427 hasReachedHeight := dv.height > 0 && printedLines+1 == dv.height
428 isLastHunk := i+1 == len(dv.unified.Hunks)
429 isLastLine := j+1 == len(h.Lines)
430 if hasReachedHeight && (!isLastHunk || !isLastLine) {
431 if shouldWrite() {
432 ls := dv.lineStyleForType(l.Kind)
433 if dv.lineNumbers {
434 b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
435 b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
436 }
437 b.WriteString(fullContentStyle.Render(
438 ls.Code.Width(dv.fullCodeWidth).Render(" …"),
439 ))
440 b.WriteRune('\n')
441 }
442 break outer
443 }
444
445 switch l.Kind {
446 case udiff.Equal:
447 if shouldWrite() {
448 ls := dv.style.EqualLine
449 content, leadingEllipsis := getContent(l.Content, ls)
450 if dv.lineNumbers {
451 b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
452 b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
453 }
454 b.WriteString(fullContentStyle.Render(
455 ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", " ") + content),
456 ))
457 }
458 beforeLine++
459 afterLine++
460 case udiff.Insert:
461 if shouldWrite() {
462 ls := dv.style.InsertLine
463 content, leadingEllipsis := getContent(l.Content, ls)
464 if dv.lineNumbers {
465 b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
466 b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
467 }
468 b.WriteString(fullContentStyle.Render(
469 ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
470 ls.Code.Width(dv.codeWidth).Render(content),
471 ))
472 }
473 afterLine++
474 case udiff.Delete:
475 if shouldWrite() {
476 ls := dv.style.DeleteLine
477 content, leadingEllipsis := getContent(l.Content, ls)
478 if dv.lineNumbers {
479 b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
480 b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
481 }
482 b.WriteString(fullContentStyle.Render(
483 ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
484 ls.Code.Width(dv.codeWidth).Render(content),
485 ))
486 }
487 beforeLine++
488 }
489 if shouldWrite() {
490 b.WriteRune('\n')
491 }
492
493 printedLines++
494 }
495 }
496
497 for printedLines < dv.height {
498 if shouldWrite() {
499 ls := dv.style.MissingLine
500 if dv.lineNumbers {
501 b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
502 b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
503 }
504 b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(" "))
505 b.WriteRune('\n')
506 }
507 printedLines++
508 }
509
510 return b.String()
511}
512
513// renderSplit renders the split (side-by-side) diff view as a string.
514func (dv *DiffView) renderSplit() string {
515 var b strings.Builder
516
517 beforeFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
518 afterFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth + btoi(dv.extraColOnAfter))
519 printedLines := -dv.yOffset
520 shouldWrite := func() bool { return printedLines >= 0 }
521
522 getContent := func(in string, ls LineStyle) (content string, leadingEllipsis bool) {
523 content = strings.ReplaceAll(in, "\r\n", "\n")
524 content = strings.TrimSuffix(content, "\n")
525 content = dv.hightlightCode(content, ls.Code.GetBackground())
526 content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
527 content = ansi.Truncate(content, dv.codeWidth, "…")
528 leadingEllipsis = dv.xOffset > 0 && strings.TrimSpace(content) != ""
529 return
530 }
531
532outer:
533 for i, h := range dv.splitHunks {
534 if shouldWrite() {
535 ls := dv.style.DividerLine
536 if dv.lineNumbers {
537 b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
538 }
539 content := ansi.Truncate(dv.hunkLineFor(dv.unified.Hunks[i]), dv.fullCodeWidth, "…")
540 b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(content))
541 if dv.lineNumbers {
542 b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
543 }
544 b.WriteString(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
545 b.WriteRune('\n')
546 }
547 printedLines++
548
549 beforeLine := h.fromLine
550 afterLine := h.toLine
551
552 for j, l := range h.lines {
553 // print ellipis if we don't have enough space to print the rest of the diff
554 hasReachedHeight := dv.height > 0 && printedLines+1 == dv.height
555 isLastHunk := i+1 == len(dv.unified.Hunks)
556 isLastLine := j+1 == len(h.lines)
557 if hasReachedHeight && (!isLastHunk || !isLastLine) {
558 if shouldWrite() {
559 ls := dv.style.MissingLine
560 if l.before != nil {
561 ls = dv.lineStyleForType(l.before.Kind)
562 }
563 if dv.lineNumbers {
564 b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
565 }
566 b.WriteString(beforeFullContentStyle.Render(
567 ls.Code.Width(dv.fullCodeWidth).Render(" …"),
568 ))
569 ls = dv.style.MissingLine
570 if l.after != nil {
571 ls = dv.lineStyleForType(l.after.Kind)
572 }
573 if dv.lineNumbers {
574 b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
575 }
576 b.WriteString(afterFullContentStyle.Render(
577 ls.Code.Width(dv.fullCodeWidth).Render(" …"),
578 ))
579 b.WriteRune('\n')
580 }
581 break outer
582 }
583
584 switch {
585 case l.before == nil:
586 if shouldWrite() {
587 ls := dv.style.MissingLine
588 if dv.lineNumbers {
589 b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
590 }
591 b.WriteString(beforeFullContentStyle.Render(
592 ls.Code.Width(dv.fullCodeWidth).Render(" "),
593 ))
594 }
595 case l.before.Kind == udiff.Equal:
596 if shouldWrite() {
597 ls := dv.style.EqualLine
598 content, leadingEllipsis := getContent(l.before.Content, ls)
599 if dv.lineNumbers {
600 b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
601 }
602 b.WriteString(beforeFullContentStyle.Render(
603 ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", " ") + content),
604 ))
605 }
606 beforeLine++
607 case l.before.Kind == udiff.Delete:
608 if shouldWrite() {
609 ls := dv.style.DeleteLine
610 content, leadingEllipsis := getContent(l.before.Content, ls)
611 if dv.lineNumbers {
612 b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
613 }
614 b.WriteString(beforeFullContentStyle.Render(
615 ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
616 ls.Code.Width(dv.codeWidth).Render(content),
617 ))
618 }
619 beforeLine++
620 }
621
622 switch {
623 case l.after == nil:
624 if shouldWrite() {
625 ls := dv.style.MissingLine
626 if dv.lineNumbers {
627 b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
628 }
629 b.WriteString(afterFullContentStyle.Render(
630 ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "),
631 ))
632 }
633 case l.after.Kind == udiff.Equal:
634 if shouldWrite() {
635 ls := dv.style.EqualLine
636 content, leadingEllipsis := getContent(l.after.Content, ls)
637 if dv.lineNumbers {
638 b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
639 }
640 b.WriteString(afterFullContentStyle.Render(
641 ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(ternary(leadingEllipsis, " …", " ") + content),
642 ))
643 }
644 afterLine++
645 case l.after.Kind == udiff.Insert:
646 if shouldWrite() {
647 ls := dv.style.InsertLine
648 content, leadingEllipsis := getContent(l.after.Content, ls)
649 if dv.lineNumbers {
650 b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
651 }
652 b.WriteString(afterFullContentStyle.Render(
653 ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
654 ls.Code.Width(dv.codeWidth+btoi(dv.extraColOnAfter)).Render(content),
655 ))
656 }
657 afterLine++
658 }
659
660 if shouldWrite() {
661 b.WriteRune('\n')
662 }
663
664 printedLines++
665 }
666 }
667
668 for printedLines < dv.height {
669 if shouldWrite() {
670 ls := dv.style.MissingLine
671 if dv.lineNumbers {
672 b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
673 }
674 b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(" "))
675 if dv.lineNumbers {
676 b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
677 }
678 b.WriteString(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
679 b.WriteRune('\n')
680 }
681 printedLines++
682 }
683
684 return b.String()
685}
686
687// hunkLineFor formats the header line for a hunk in the unified diff view.
688func (dv *DiffView) hunkLineFor(h *udiff.Hunk) string {
689 beforeShownLines, afterShownLines := dv.hunkShownLines(h)
690
691 return fmt.Sprintf(
692 " @@ -%d,%d +%d,%d @@ ",
693 h.FromLine,
694 beforeShownLines,
695 h.ToLine,
696 afterShownLines,
697 )
698}
699
700// hunkShownLines calculates the number of lines shown in a hunk for both before
701// and after versions.
702func (dv *DiffView) hunkShownLines(h *udiff.Hunk) (before, after int) {
703 for _, l := range h.Lines {
704 switch l.Kind {
705 case udiff.Equal:
706 before++
707 after++
708 case udiff.Insert:
709 after++
710 case udiff.Delete:
711 before++
712 }
713 }
714 return
715}
716
717func (dv *DiffView) lineStyleForType(t udiff.OpKind) LineStyle {
718 switch t {
719 case udiff.Equal:
720 return dv.style.EqualLine
721 case udiff.Insert:
722 return dv.style.InsertLine
723 case udiff.Delete:
724 return dv.style.DeleteLine
725 default:
726 return dv.style.MissingLine
727 }
728}
729
730func (dv *DiffView) hightlightCode(source string, bgColor color.Color) string {
731 if dv.chromaStyle == nil {
732 return source
733 }
734
735 // Create cache key from content and background color
736 cacheKey := dv.createSyntaxCacheKey(source, bgColor)
737
738 // Check if we already have this highlighted
739 if cached, exists := dv.syntaxCache[cacheKey]; exists {
740 return cached
741 }
742
743 l := dv.getChromaLexer()
744 f := dv.getChromaFormatter(bgColor)
745
746 it, err := l.Tokenise(nil, source)
747 if err != nil {
748 return source
749 }
750
751 var b strings.Builder
752 if err := f.Format(&b, dv.chromaStyle, it); err != nil {
753 return source
754 }
755
756 result := b.String()
757
758 // Cache the result for future use
759 dv.syntaxCache[cacheKey] = result
760
761 return result
762}
763
764// createSyntaxCacheKey creates a cache key from source content and background color.
765// We use a simple hash to keep memory usage reasonable.
766func (dv *DiffView) createSyntaxCacheKey(source string, bgColor color.Color) string {
767 // Convert color to string representation
768 r, g, b, a := bgColor.RGBA()
769 colorStr := fmt.Sprintf("%d,%d,%d,%d", r, g, b, a)
770
771 // Create a hash of the content + color to use as cache key
772 h := sha256.New()
773 h.Write([]byte(source))
774 h.Write([]byte(colorStr))
775 return fmt.Sprintf("%x", h.Sum(nil))
776}
777
778func (dv *DiffView) getChromaLexer() chroma.Lexer {
779 if dv.cachedLexer != nil {
780 return dv.cachedLexer
781 }
782
783 l := lexers.Match(dv.before.path)
784 if l == nil {
785 l = lexers.Analyse(dv.before.content)
786 }
787 if l == nil {
788 l = lexers.Fallback
789 }
790 dv.cachedLexer = chroma.Coalesce(l)
791 return dv.cachedLexer
792}
793
794func (dv *DiffView) getChromaFormatter(bgColor color.Color) chroma.Formatter {
795 return chromaFormatter{
796 bgColor: bgColor,
797 }
798}