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"
20 leadingSymbolsSize = 2
32 layoutUnified layout = iota + 1
36// DiffView represents a view for displaying differences between two files.
51 chromaStyle *chroma.Style
55 unified udiff.UnifiedDiff
58 splitHunks []splitHunk
62 fullCodeWidth int // with leading symbols
63 extraColOnAfter bool // add extra column on after panel
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.
78 layout: layoutUnified,
79 contextLines: udiff.DefaultContextLines,
82 syntaxCache: make(map[string]string),
84 dv.style = DefaultDarkStyle()
88// Unified sets the layout of the DiffView to unified.
89func (dv *DiffView) Unified() *DiffView {
90 dv.layout = layoutUnified
94// Split sets the layout of the DiffView to split (side-by-side).
95func (dv *DiffView) Split() *DiffView {
96 dv.layout = layoutSplit
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
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
116// FileName sets the file name header to display above the diff.
117func (dv *DiffView) FileName(name string) *DiffView {
122// clearCaches clears all caches when content or major settings change.
123func (dv *DiffView) clearCaches() {
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
135// Style sets the style for the DiffView.
136func (dv *DiffView) Style(style Style) *DiffView {
141// LineNumbers sets whether to display line numbers in the DiffView.
142func (dv *DiffView) LineNumbers(lineNumbers bool) *DiffView {
143 dv.lineNumbers = lineNumbers
147// Height sets the height of the DiffView.
148func (dv *DiffView) Height(height int) *DiffView {
153// Width sets the width of the DiffView.
154func (dv *DiffView) Width(width int) *DiffView {
159// XOffset sets the horizontal offset for the DiffView.
160func (dv *DiffView) XOffset(xOffset int) *DiffView {
165// YOffset sets the vertical offset for the DiffView.
166func (dv *DiffView) YOffset(yOffset int) *DiffView {
171// InfiniteYScroll allows the YOffset to scroll beyond the last line.
172func (dv *DiffView) InfiniteYScroll(infiniteYScroll bool) *DiffView {
173 dv.infiniteYScroll = infiniteYScroll
177// TabWidth sets the tab width. Only relevant for code that contains tabs, like
179func (dv *DiffView) TabWidth(tabWidth int) *DiffView {
180 dv.tabWidth = tabWidth
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()
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)
203// String returns the string representation of the DiffView.
204func (dv *DiffView) String() string {
205 dv.normalizeLineEndings()
207 if err := dv.computeDiff(); err != nil {
210 dv.convertDiffToSplit()
213 dv.detectTotalLines()
214 dv.preventInfiniteYScroll()
222 style := lipgloss.NewStyle()
224 style = style.MaxWidth(dv.width)
227 style = style.MaxHeight(dv.height)
232 return style.Render(strings.TrimSuffix(dv.renderUnified(), "\n"))
234 return style.Render(strings.TrimSuffix(dv.renderSplit(), "\n"))
236 panic("unknown diffview layout")
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 {
260 dv.edits = udiff.Lines(
264 dv.unified, dv.err = udiff.ToUnifiedDiff(
274// convertDiffToSplit converts the unified diff to a split diff if the layout is
276func (dv *DiffView) convertDiffToSplit() {
277 if dv.layout != layoutSplit {
281 dv.splitHunks = make([]splitHunk, len(dv.unified.Hunks))
282 for i, h := range dv.unified.Hunks {
283 dv.splitHunks[i] = hunkToSplit(h)
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)
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))))
312func (dv *DiffView) detectTotalLines() {
315 if dv.fileName != "" {
321 for _, h := range dv.unified.Hunks {
322 dv.totalLines += 1 + len(h.Lines)
325 for _, h := range dv.splitHunks {
326 dv.totalLines += 1 + len(h.lines)
331func (dv *DiffView) preventInfiniteYScroll() {
332 if dv.infiniteYScroll {
336 // clamp yOffset to prevent scrolling beyond the last line
338 maxYOffset := max(0, dv.totalLines-dv.height)
339 dv.yOffset = min(dv.yOffset, maxYOffset)
341 // if no height limit, ensure yOffset doesn't exceed total lines
342 dv.yOffset = min(dv.yOffset, max(0, dv.totalLines-1))
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() {
351 dv.detectUnifiedCodeWidth()
353 dv.detectSplitCodeWidth()
355 dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
358// detectUnifiedCodeWidth calculates the maximum width of code lines in a
360func (dv *DiffView) detectUnifiedCodeWidth() {
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)
373// detectSplitCodeWidth calculates the maximum width of code lines in a
375func (dv *DiffView) detectSplitCodeWidth() {
378 for i, h := range dv.splitHunks {
379 shownLines := ansi.StringWidth(dv.hunkLineFor(dv.unified.Hunks[i]))
381 for _, l := range h.lines {
383 codeWidth := ansi.StringWidth(strings.TrimSuffix(l.before.Content, "\n")) + 1
384 dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
387 codeWidth := ansi.StringWidth(strings.TrimSuffix(l.after.Content, "\n")) + 1
388 dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
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
401 dv.codeWidth = dv.width - fullNumWidth - leadingSymbolsSize
403 remainingWidth := dv.width - fullNumWidth - leadingSymbolsSize*2
404 dv.codeWidth = remainingWidth / 2
405 dv.extraColOnAfter = isOdd(remainingWidth)
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
429 for i, h := range dv.unified.Hunks {
430 // Render file name header before the first hunk.
431 if i == 0 && dv.fileName != "" {
433 ls := dv.style.Filename
435 b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
436 b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
438 content := ansi.Truncate(" "+dv.fileName, dv.fullCodeWidth, "…")
439 b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(content))
445 ls := dv.style.DividerLine
447 b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
448 b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
450 content := ansi.Truncate(dv.hunkLineFor(h), dv.fullCodeWidth, "…")
451 b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(content))
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) {
466 ls := dv.lineStyleForType(l.Kind)
468 b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
469 b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
471 b.WriteString(fullContentStyle.Render(
472 ls.Code.Width(dv.fullCodeWidth).Render(" …"),
482 ls := dv.style.EqualLine
483 content, leadingEllipsis := getContent(l.Content, ls)
485 b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
486 b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
488 b.WriteString(fullContentStyle.Render(
489 ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", " ") + content),
496 ls := dv.style.InsertLine
497 content, leadingEllipsis := getContent(l.Content, ls)
499 b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
500 b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
502 b.WriteString(fullContentStyle.Render(
503 ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
504 ls.Code.Width(dv.codeWidth).Render(content),
510 ls := dv.style.DeleteLine
511 content, leadingEllipsis := getContent(l.Content, ls)
513 b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
514 b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
516 b.WriteString(fullContentStyle.Render(
517 ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
518 ls.Code.Width(dv.codeWidth).Render(content),
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
553 for i, h := range dv.splitHunks {
554 // Render file name header before the first hunk.
555 if i == 0 && dv.fileName != "" {
557 ls := dv.style.Filename
559 b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
561 content := ansi.Truncate(" "+dv.fileName, dv.fullCodeWidth, "…")
562 b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(content))
564 b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
566 b.WriteString(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
572 ls := dv.style.DividerLine
574 b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
576 content := ansi.Truncate(dv.hunkLineFor(dv.unified.Hunks[i]), dv.fullCodeWidth, "…")
577 b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(content))
579 b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
581 b.WriteString(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
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) {
596 ls := dv.style.MissingLine
598 ls = dv.lineStyleForType(l.before.Kind)
601 b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
603 b.WriteString(beforeFullContentStyle.Render(
604 ls.Code.Width(dv.fullCodeWidth).Render(" …"),
606 ls = dv.style.MissingLine
608 ls = dv.lineStyleForType(l.after.Kind)
611 b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
613 b.WriteString(afterFullContentStyle.Render(
614 ls.Code.Width(dv.fullCodeWidth).Render(" …"),
622 case l.before == nil:
624 ls := dv.style.MissingLine
626 b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
628 b.WriteString(beforeFullContentStyle.Render(
629 ls.Code.Width(dv.fullCodeWidth).Render(" "),
632 case l.before.Kind == udiff.Equal:
634 ls := dv.style.EqualLine
635 content, leadingEllipsis := getContent(l.before.Content, ls)
637 b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
639 b.WriteString(beforeFullContentStyle.Render(
640 ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", " ") + content),
644 case l.before.Kind == udiff.Delete:
646 ls := dv.style.DeleteLine
647 content, leadingEllipsis := getContent(l.before.Content, ls)
649 b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
651 b.WriteString(beforeFullContentStyle.Render(
652 ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
653 ls.Code.Width(dv.codeWidth).Render(content),
662 ls := dv.style.MissingLine
664 b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
666 b.WriteString(afterFullContentStyle.Render(
667 ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "),
670 case l.after.Kind == udiff.Equal:
672 ls := dv.style.EqualLine
673 content, leadingEllipsis := getContent(l.after.Content, ls)
675 b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
677 b.WriteString(afterFullContentStyle.Render(
678 ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(ternary(leadingEllipsis, " …", " ") + content),
682 case l.after.Kind == udiff.Insert:
684 ls := dv.style.InsertLine
685 content, leadingEllipsis := getContent(l.after.Content, ls)
687 b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
689 b.WriteString(afterFullContentStyle.Render(
690 ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
691 ls.Code.Width(dv.codeWidth+btoi(dv.extraColOnAfter)).Render(content),
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)
713 " @@ -%d,%d +%d,%d @@ ",
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 {
738func (dv *DiffView) lineStyleForType(t udiff.OpKind) LineStyle {
741 return dv.style.EqualLine
743 return dv.style.InsertLine
745 return dv.style.DeleteLine
747 return dv.style.MissingLine
751func (dv *DiffView) hightlightCode(source string, bgColor color.Color) string {
752 if dv.chromaStyle == nil {
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 {
764 l := dv.getChromaLexer()
765 f := dv.getChromaFormatter(bgColor)
767 it, err := l.Tokenise(nil, source)
772 var b strings.Builder
773 if err := f.Format(&b, dv.chromaStyle, it); err != nil {
779 // Cache the result for future use
780 dv.syntaxCache[cacheKey] = 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
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
804 l := lexers.Match(dv.before.path)
806 l = lexers.Analyse(dv.before.content)
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)
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list