on ee47eb8 help sign in
crush/internal/ui/diffview/diffview.go
823 lines 23 kB history raw
1package diffview
3import (
4 "fmt"
5 "image/color"
6 "strconv"
7 "strings"
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"
19const (
20 leadingSymbolsSize = 2
21 lineNumPadding = 1
24type file struct {
25 path string
26 content string
29type layout int
31const (
32 layoutUnified layout = iota + 1
33 layoutSplit
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
53 isComputed bool
54 err error
55 unified udiff.UnifiedDiff
56 edits []udiff.Edit
58 splitHunks []splitHunk
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
67 // Cache lexer to avoid expensive file pattern matching on every line
68 cachedLexer chroma.Lexer
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
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
88// Unified sets the layout of the DiffView to unified.
89func (dv *DiffView) Unified() *DiffView {
90 dv.layout = layoutUnified
91 return dv
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
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
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
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
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
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
135// Style sets the style for the DiffView.
136func (dv *DiffView) Style(style Style) *DiffView {
137 dv.style = style
138 return dv
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
147// Height sets the height of the DiffView.
148func (dv *DiffView) Height(height int) *DiffView {
149 dv.height = height
150 return dv
153// Width sets the width of the DiffView.
154func (dv *DiffView) Width(width int) *DiffView {
155 dv.width = width
156 return dv
159// XOffset sets the horizontal offset for the DiffView.
160func (dv *DiffView) XOffset(xOffset int) *DiffView {
161 dv.xOffset = xOffset
162 return dv
165// YOffset sets the vertical offset for the DiffView.
166func (dv *DiffView) YOffset(yOffset int) *DiffView {
167 dv.yOffset = yOffset
168 return dv
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
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
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
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 }
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()
216 if dv.width <= 0 {
217 dv.detectCodeWidth()
218 } else {
219 dv.resizeCodeWidth()
220 }
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 }
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 }
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")
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)
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
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 }
281 dv.splitHunks = make([]splitHunk, len(dv.unified.Hunks))
282 for i, h := range dv.unified.Hunks {
283 dv.splitHunks[i] = hunkToSplit(h)
284 }
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)
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
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 }
312func (dv *DiffView) detectTotalLines() {
313 dv.totalLines = 0
315 if dv.fileName != "" {
316 dv.totalLines++
317 }
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 }
331func (dv *DiffView) preventInfiniteYScroll() {
332 if dv.infiniteYScroll {
333 return
334 }
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
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
358// detectUnifiedCodeWidth calculates the maximum width of code lines in a
359// unified diff.
360func (dv *DiffView) detectUnifiedCodeWidth() {
361 dv.codeWidth = 0
363 for _, h := range dv.unified.Hunks {
364 shownLines := ansi.StringWidth(dv.hunkLineFor(h))
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 }
373// detectSplitCodeWidth calculates the maximum width of code lines in a
374// split diff.
375func (dv *DiffView) detectSplitCodeWidth() {
376 dv.codeWidth = 0
378 for i, h := range dv.splitHunks {
379 shownLines := ansi.StringWidth(dv.hunkLineFor(dv.unified.Hunks[i]))
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 }
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
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 }
408 dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
411// renderUnified renders the unified diff view as a string.
412func (dv *DiffView) renderUnified() string {
413 var b strings.Builder
415 fullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
416 printedLines := -dv.yOffset
417 shouldWrite := func() bool { return printedLines >= 0 }
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 }
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++
456 beforeLine := h.FromLine
457 afterLine := h.ToLine
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 }
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 }
527 printedLines++
528 }
529 }
531 return b.String()
534// renderSplit renders the split (side-by-side) diff view as a string.
535func (dv *DiffView) renderSplit() string {
536 var b strings.Builder
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 }
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 }
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++
586 beforeLine := h.fromLine
587 afterLine := h.toLine
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 }
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 }
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 }
697 if shouldWrite() {
698 b.WriteRune('\n')
699 }
701 printedLines++
702 }
703 }
705 return b.String()
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)
712 return fmt.Sprintf(
713 " @@ -%d,%d +%d,%d @@ ",
714 h.FromLine,
715 beforeShownLines,
716 h.ToLine,
717 afterShownLines,
718 )
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
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 }
751func (dv *DiffView) hightlightCode(source string, bgColor color.Color) string {
752 if dv.chromaStyle == nil {
753 return source
754 }
756 // Create cache key from content and background color
757 cacheKey := dv.createSyntaxCacheKey(source, bgColor)
759 // Check if we already have this highlighted
760 if cached, exists := dv.syntaxCache[cacheKey]; exists {
761 return cached
762 }
764 l := dv.getChromaLexer()
765 f := dv.getChromaFormatter(bgColor)
767 it, err := l.Tokenise(nil, source)
768 if err != nil {
769 return source
770 }
772 var b strings.Builder
773 if err := f.Format(&b, dv.chromaStyle, it); err != nil {
774 return source
775 }
777 result := b.String()
779 // Cache the result for future use
780 dv.syntaxCache[cacheKey] = result
782 return result
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)
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))
799func (dv *DiffView) getChromaLexer() chroma.Lexer {
800 if dv.cachedLexer != nil {
801 return dv.cachedLexer
802 }
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
815func (dv *DiffView) getChromaFormatter(bgColor color.Color) chroma.Formatter {
816 return xchroma.Formatter(bgColor, processChromaValue)
819func processChromaValue(value string) string {
820 value = strings.TrimRight(value, "\n")
821 value = ansiext.Escape(value)
822 return value