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.
50 chromaStyle *chroma.Style
54 unified udiff.UnifiedDiff
57 splitHunks []splitHunk
61 fullCodeWidth int // with leading symbols
62 extraColOnAfter bool // add extra column on after panel
66 // Cache lexer to avoid expensive file pattern matching on every line
67 cachedLexer chroma.Lexer
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
74// New creates a new DiffView with default settings.
77 layout: layoutUnified,
78 contextLines: udiff.DefaultContextLines,
81 syntaxCache: make(map[string]string),
83 dv.style = DefaultDarkStyle()
87// Unified sets the layout of the DiffView to unified.
88func (dv *DiffView) Unified() *DiffView {
89 dv.layout = layoutUnified
93// Split sets the layout of the DiffView to split (side-by-side).
94func (dv *DiffView) Split() *DiffView {
95 dv.layout = layoutSplit
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
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
115// clearCaches clears all caches when content or major settings change.
116func (dv *DiffView) clearCaches() {
118 dv.clearSyntaxCache()
119 dv.isComputed = false
122// ContextLines sets the number of context lines for the DiffView.
123func (dv *DiffView) ContextLines(contextLines int) *DiffView {
124 dv.contextLines = contextLines
128// Style sets the style for the DiffView.
129func (dv *DiffView) Style(style Style) *DiffView {
134// LineNumbers sets whether to display line numbers in the DiffView.
135func (dv *DiffView) LineNumbers(lineNumbers bool) *DiffView {
136 dv.lineNumbers = lineNumbers
140// Height sets the height of the DiffView.
141func (dv *DiffView) Height(height int) *DiffView {
146// Width sets the width of the DiffView.
147func (dv *DiffView) Width(width int) *DiffView {
152// XOffset sets the horizontal offset for the DiffView.
153func (dv *DiffView) XOffset(xOffset int) *DiffView {
158// YOffset sets the vertical offset for the DiffView.
159func (dv *DiffView) YOffset(yOffset int) *DiffView {
164// InfiniteYScroll allows the YOffset to scroll beyond the last line.
165func (dv *DiffView) InfiniteYScroll(infiniteYScroll bool) *DiffView {
166 dv.infiniteYScroll = infiniteYScroll
170// TabWidth sets the tab width. Only relevant for code that contains tabs, like
172func (dv *DiffView) TabWidth(tabWidth int) *DiffView {
173 dv.tabWidth = tabWidth
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()
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)
196// String returns the string representation of the DiffView.
197func (dv *DiffView) String() string {
198 dv.normalizeLineEndings()
200 if err := dv.computeDiff(); err != nil {
203 dv.convertDiffToSplit()
206 dv.detectTotalLines()
207 dv.preventInfiniteYScroll()
215 style := lipgloss.NewStyle()
217 style = style.MaxWidth(dv.width)
220 style = style.MaxHeight(dv.height)
225 return style.Render(strings.TrimSuffix(dv.renderUnified(), "\n"))
227 return style.Render(strings.TrimSuffix(dv.renderSplit(), "\n"))
229 panic("unknown diffview layout")
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")
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)
247// computeDiff computes the differences between the "before" and "after" files.
248func (dv *DiffView) computeDiff() error {
253 dv.edits = udiff.Lines(
257 dv.unified, dv.err = udiff.ToUnifiedDiff(
267// convertDiffToSplit converts the unified diff to a split diff if the layout is
269func (dv *DiffView) convertDiffToSplit() {
270 if dv.layout != layoutSplit {
274 dv.splitHunks = make([]splitHunk, len(dv.unified.Hunks))
275 for i, h := range dv.unified.Hunks {
276 dv.splitHunks[i] = hunkToSplit(h)
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)
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)
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
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))))
304func (dv *DiffView) detectTotalLines() {
309 for _, h := range dv.unified.Hunks {
310 dv.totalLines += 1 + len(h.Lines)
313 for _, h := range dv.splitHunks {
314 dv.totalLines += 1 + len(h.lines)
319func (dv *DiffView) preventInfiniteYScroll() {
320 if dv.infiniteYScroll {
324 // clamp yOffset to prevent scrolling beyond the last line
326 maxYOffset := max(0, dv.totalLines-dv.height)
327 dv.yOffset = min(dv.yOffset, maxYOffset)
329 // if no height limit, ensure yOffset doesn't exceed total lines
330 dv.yOffset = min(dv.yOffset, max(0, dv.totalLines-1))
332 dv.yOffset = max(0, dv.yOffset) // ensure yOffset is not negative
335// detectCodeWidth calculates the maximum width of code lines in the diff view.
336func (dv *DiffView) detectCodeWidth() {
339 dv.detectUnifiedCodeWidth()
341 dv.detectSplitCodeWidth()
343 dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
346// detectUnifiedCodeWidth calculates the maximum width of code lines in a
348func (dv *DiffView) detectUnifiedCodeWidth() {
351 for _, h := range dv.unified.Hunks {
352 shownLines := ansi.StringWidth(dv.hunkLineFor(h))
354 for _, l := range h.Lines {
355 lineWidth := ansi.StringWidth(strings.TrimSuffix(l.Content, "\n")) + 1
356 dv.codeWidth = max(dv.codeWidth, lineWidth, shownLines)
361// detectSplitCodeWidth calculates the maximum width of code lines in a
363func (dv *DiffView) detectSplitCodeWidth() {
366 for i, h := range dv.splitHunks {
367 shownLines := ansi.StringWidth(dv.hunkLineFor(dv.unified.Hunks[i]))
369 for _, l := range h.lines {
371 codeWidth := ansi.StringWidth(strings.TrimSuffix(l.before.Content, "\n")) + 1
372 dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
375 codeWidth := ansi.StringWidth(strings.TrimSuffix(l.after.Content, "\n")) + 1
376 dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
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
389 dv.codeWidth = dv.width - fullNumWidth - leadingSymbolsSize
391 remainingWidth := dv.width - fullNumWidth - leadingSymbolsSize*2
392 dv.codeWidth = remainingWidth / 2
393 dv.extraColOnAfter = isOdd(remainingWidth)
396 dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
399// renderUnified renders the unified diff view as a string.
400func (dv *DiffView) renderUnified() string {
401 var b strings.Builder
403 fullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
404 printedLines := -dv.yOffset
405 shouldWrite := func() bool { return printedLines >= 0 }
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
417 for i, h := range dv.unified.Hunks {
419 ls := dv.style.DividerLine
421 b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
422 b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
424 content := ansi.Truncate(dv.hunkLineFor(h), dv.fullCodeWidth, "…")
425 b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(content))
430 beforeLine := h.FromLine
431 afterLine := h.ToLine
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) {
440 ls := dv.lineStyleForType(l.Kind)
442 b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
443 b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
445 b.WriteString(fullContentStyle.Render(
446 ls.Code.Width(dv.fullCodeWidth).Render(" …"),
456 ls := dv.style.EqualLine
457 content, leadingEllipsis := getContent(l.Content, ls)
459 b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
460 b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
462 b.WriteString(fullContentStyle.Render(
463 ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", " ") + content),
470 ls := dv.style.InsertLine
471 content, leadingEllipsis := getContent(l.Content, ls)
473 b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
474 b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
476 b.WriteString(fullContentStyle.Render(
477 ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
478 ls.Code.Width(dv.codeWidth).Render(content),
484 ls := dv.style.DeleteLine
485 content, leadingEllipsis := getContent(l.Content, ls)
487 b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
488 b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
490 b.WriteString(fullContentStyle.Render(
491 ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
492 ls.Code.Width(dv.codeWidth).Render(content),
508// renderSplit renders the split (side-by-side) diff view as a string.
509func (dv *DiffView) renderSplit() string {
510 var b strings.Builder
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 }
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
527 for i, h := range dv.splitHunks {
529 ls := dv.style.DividerLine
531 b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
533 content := ansi.Truncate(dv.hunkLineFor(dv.unified.Hunks[i]), dv.fullCodeWidth, "…")
534 b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(content))
536 b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
538 b.WriteString(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
543 beforeLine := h.fromLine
544 afterLine := h.toLine
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) {
553 ls := dv.style.MissingLine
555 ls = dv.lineStyleForType(l.before.Kind)
558 b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
560 b.WriteString(beforeFullContentStyle.Render(
561 ls.Code.Width(dv.fullCodeWidth).Render(" …"),
563 ls = dv.style.MissingLine
565 ls = dv.lineStyleForType(l.after.Kind)
568 b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
570 b.WriteString(afterFullContentStyle.Render(
571 ls.Code.Width(dv.fullCodeWidth).Render(" …"),
579 case l.before == nil:
581 ls := dv.style.MissingLine
583 b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
585 b.WriteString(beforeFullContentStyle.Render(
586 ls.Code.Width(dv.fullCodeWidth).Render(" "),
589 case l.before.Kind == udiff.Equal:
591 ls := dv.style.EqualLine
592 content, leadingEllipsis := getContent(l.before.Content, ls)
594 b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
596 b.WriteString(beforeFullContentStyle.Render(
597 ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", " ") + content),
601 case l.before.Kind == udiff.Delete:
603 ls := dv.style.DeleteLine
604 content, leadingEllipsis := getContent(l.before.Content, ls)
606 b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
608 b.WriteString(beforeFullContentStyle.Render(
609 ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
610 ls.Code.Width(dv.codeWidth).Render(content),
619 ls := dv.style.MissingLine
621 b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
623 b.WriteString(afterFullContentStyle.Render(
624 ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "),
627 case l.after.Kind == udiff.Equal:
629 ls := dv.style.EqualLine
630 content, leadingEllipsis := getContent(l.after.Content, ls)
632 b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
634 b.WriteString(afterFullContentStyle.Render(
635 ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(ternary(leadingEllipsis, " …", " ") + content),
639 case l.after.Kind == udiff.Insert:
641 ls := dv.style.InsertLine
642 content, leadingEllipsis := getContent(l.after.Content, ls)
644 b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
646 b.WriteString(afterFullContentStyle.Render(
647 ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
648 ls.Code.Width(dv.codeWidth+btoi(dv.extraColOnAfter)).Render(content),
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)
670 " @@ -%d,%d +%d,%d @@ ",
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 {
695func (dv *DiffView) lineStyleForType(t udiff.OpKind) LineStyle {
698 return dv.style.EqualLine
700 return dv.style.InsertLine
702 return dv.style.DeleteLine
704 return dv.style.MissingLine
708func (dv *DiffView) hightlightCode(source string, bgColor color.Color) string {
709 if dv.chromaStyle == nil {
713 // Create cache key from content and background color
714 cacheKey := dv.createSyntaxCacheKey(source, bgColor)
716 // Check if we already have this highlighted
717 if cached, exists := dv.syntaxCache[cacheKey]; exists {
721 l := dv.getChromaLexer()
722 f := dv.getChromaFormatter(bgColor)
724 it, err := l.Tokenise(nil, source)
729 var b strings.Builder
730 if err := f.Format(&b, dv.chromaStyle, it); err != nil {
736 // Cache the result for future use
737 dv.syntaxCache[cacheKey] = result
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)
749 // Create a hash of the content + color to use as cache key
751 h.Write([]byte(source))
752 h.Write([]byte(colorStr))
753 return fmt.Sprintf("%x", h.Sum(nil))
756func (dv *DiffView) getChromaLexer() chroma.Lexer {
757 if dv.cachedLexer != nil {
758 return dv.cachedLexer
761 l := lexers.Match(dv.before.path)
763 l = lexers.Analyse(dv.before.content)
768 dv.cachedLexer = chroma.Coalesce(l)
769 return dv.cachedLexer
772func (dv *DiffView) getChromaFormatter(bgColor color.Color) chroma.Formatter {
773 return xchroma.Formatter(bgColor, processChromaValue)
776func processChromaValue(value string) string {
777 value = strings.TrimRight(value, "\n")
778 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