9 "github.com/alecthomas/chroma/v2"
10 "github.com/alecthomas/chroma/v2/lexers"
11 "github.com/aymanbagabas/go-udiff"
12 "github.com/charmbracelet/lipgloss/v2"
13 "github.com/charmbracelet/x/ansi"
14 "github.com/zeebo/xxh3"
18 leadingSymbolsSize = 2
30 layoutUnified layout = iota + 1
34// DiffView represents a view for displaying differences between two files.
48 chromaStyle *chroma.Style
52 unified udiff.UnifiedDiff
55 splitHunks []splitHunk
59 fullCodeWidth int // with leading symbols
60 extraColOnAfter bool // add extra column on after panel
64 // Cache lexer to avoid expensive file pattern matching on every line
65 cachedLexer chroma.Lexer
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
72// New creates a new DiffView with default settings.
75 layout: layoutUnified,
76 contextLines: udiff.DefaultContextLines,
79 syntaxCache: make(map[string]string),
81 dv.style = DefaultDarkStyle()
85// Unified sets the layout of the DiffView to unified.
86func (dv *DiffView) Unified() *DiffView {
87 dv.layout = layoutUnified
91// Split sets the layout of the DiffView to split (side-by-side).
92func (dv *DiffView) Split() *DiffView {
93 dv.layout = layoutSplit
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
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
113// clearCaches clears all caches when content or major settings change.
114func (dv *DiffView) clearCaches() {
116 dv.clearSyntaxCache()
117 dv.isComputed = false
120// ContextLines sets the number of context lines for the DiffView.
121func (dv *DiffView) ContextLines(contextLines int) *DiffView {
122 dv.contextLines = contextLines
126// Style sets the style for the DiffView.
127func (dv *DiffView) Style(style Style) *DiffView {
132// LineNumbers sets whether to display line numbers in the DiffView.
133func (dv *DiffView) LineNumbers(lineNumbers bool) *DiffView {
134 dv.lineNumbers = lineNumbers
138// Height sets the height of the DiffView.
139func (dv *DiffView) Height(height int) *DiffView {
144// Width sets the width of the DiffView.
145func (dv *DiffView) Width(width int) *DiffView {
150// XOffset sets the horizontal offset for the DiffView.
151func (dv *DiffView) XOffset(xOffset int) *DiffView {
156// YOffset sets the vertical offset for the DiffView.
157func (dv *DiffView) YOffset(yOffset int) *DiffView {
162// InfiniteYScroll allows the YOffset to scroll beyond the last line.
163func (dv *DiffView) InfiniteYScroll(infiniteYScroll bool) *DiffView {
164 dv.infiniteYScroll = infiniteYScroll
168// TabWidth sets the tab width. Only relevant for code that contains tabs, like
170func (dv *DiffView) TabWidth(tabWidth int) *DiffView {
171 dv.tabWidth = tabWidth
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()
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)
194// String returns the string representation of the DiffView.
195func (dv *DiffView) String() string {
196 dv.normalizeLineEndings()
198 if err := dv.computeDiff(); err != nil {
201 dv.convertDiffToSplit()
204 dv.detectTotalLines()
205 dv.preventInfiniteYScroll()
213 style := lipgloss.NewStyle()
215 style = style.MaxWidth(dv.width)
218 style = style.MaxHeight(dv.height)
223 return style.Render(strings.TrimSuffix(dv.renderUnified(), "\n"))
225 return style.Render(strings.TrimSuffix(dv.renderSplit(), "\n"))
227 panic("unknown diffview layout")
231// normalizeLineEndings ensures the file contents use Unix-style line endings.
232func (dv *DiffView) normalizeLineEndings() {
233 dv.before.content = strings.ReplaceAll(dv.before.content, "\r\n", "\n")
234 dv.after.content = strings.ReplaceAll(dv.after.content, "\r\n", "\n")
237// replaceTabs replaces tabs in the before and after file contents with spaces
238// according to the specified tab width.
239func (dv *DiffView) replaceTabs() {
240 spaces := strings.Repeat(" ", dv.tabWidth)
241 dv.before.content = strings.ReplaceAll(dv.before.content, "\t", spaces)
242 dv.after.content = strings.ReplaceAll(dv.after.content, "\t", spaces)
245// computeDiff computes the differences between the "before" and "after" files.
246func (dv *DiffView) computeDiff() error {
251 dv.edits = udiff.Strings(
255 dv.unified, dv.err = udiff.ToUnifiedDiff(
265// convertDiffToSplit converts the unified diff to a split diff if the layout is
267func (dv *DiffView) convertDiffToSplit() {
268 if dv.layout != layoutSplit {
272 dv.splitHunks = make([]splitHunk, len(dv.unified.Hunks))
273 for i, h := range dv.unified.Hunks {
274 dv.splitHunks[i] = hunkToSplit(h)
278// adjustStyles adjusts adds padding and alignment to the styles.
279func (dv *DiffView) adjustStyles() {
280 setPadding := func(s lipgloss.Style) lipgloss.Style {
281 return s.Padding(0, lineNumPadding).Align(lipgloss.Right)
283 dv.style.MissingLine.LineNumber = setPadding(dv.style.MissingLine.LineNumber)
284 dv.style.DividerLine.LineNumber = setPadding(dv.style.DividerLine.LineNumber)
285 dv.style.EqualLine.LineNumber = setPadding(dv.style.EqualLine.LineNumber)
286 dv.style.InsertLine.LineNumber = setPadding(dv.style.InsertLine.LineNumber)
287 dv.style.DeleteLine.LineNumber = setPadding(dv.style.DeleteLine.LineNumber)
290// detectNumDigits calculates the maximum number of digits needed for before and
291// after line numbers.
292func (dv *DiffView) detectNumDigits() {
293 dv.beforeNumDigits = 0
294 dv.afterNumDigits = 0
296 for _, h := range dv.unified.Hunks {
297 dv.beforeNumDigits = max(dv.beforeNumDigits, len(strconv.Itoa(h.FromLine+len(h.Lines))))
298 dv.afterNumDigits = max(dv.afterNumDigits, len(strconv.Itoa(h.ToLine+len(h.Lines))))
302func (dv *DiffView) detectTotalLines() {
307 for _, h := range dv.unified.Hunks {
308 dv.totalLines += 1 + len(h.Lines)
311 for _, h := range dv.splitHunks {
312 dv.totalLines += 1 + len(h.lines)
317func (dv *DiffView) preventInfiniteYScroll() {
318 if dv.infiniteYScroll {
322 // clamp yOffset to prevent scrolling beyond the last line
324 maxYOffset := max(0, dv.totalLines-dv.height)
325 dv.yOffset = min(dv.yOffset, maxYOffset)
327 // if no height limit, ensure yOffset doesn't exceed total lines
328 dv.yOffset = min(dv.yOffset, max(0, dv.totalLines-1))
330 dv.yOffset = max(0, dv.yOffset) // ensure yOffset is not negative
333// detectCodeWidth calculates the maximum width of code lines in the diff view.
334func (dv *DiffView) detectCodeWidth() {
337 dv.detectUnifiedCodeWidth()
339 dv.detectSplitCodeWidth()
341 dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
344// detectUnifiedCodeWidth calculates the maximum width of code lines in a
346func (dv *DiffView) detectUnifiedCodeWidth() {
349 for _, h := range dv.unified.Hunks {
350 shownLines := ansi.StringWidth(dv.hunkLineFor(h))
352 for _, l := range h.Lines {
353 lineWidth := ansi.StringWidth(strings.TrimSuffix(l.Content, "\n")) + 1
354 dv.codeWidth = max(dv.codeWidth, lineWidth, shownLines)
359// detectSplitCodeWidth calculates the maximum width of code lines in a
361func (dv *DiffView) detectSplitCodeWidth() {
364 for i, h := range dv.splitHunks {
365 shownLines := ansi.StringWidth(dv.hunkLineFor(dv.unified.Hunks[i]))
367 for _, l := range h.lines {
369 codeWidth := ansi.StringWidth(strings.TrimSuffix(l.before.Content, "\n")) + 1
370 dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
373 codeWidth := ansi.StringWidth(strings.TrimSuffix(l.after.Content, "\n")) + 1
374 dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
380// resizeCodeWidth resizes the code width to fit within the specified width.
381func (dv *DiffView) resizeCodeWidth() {
382 fullNumWidth := dv.beforeNumDigits + dv.afterNumDigits
383 fullNumWidth += lineNumPadding * 4 // left and right padding for both line numbers
387 dv.codeWidth = dv.width - fullNumWidth - leadingSymbolsSize
389 remainingWidth := dv.width - fullNumWidth - leadingSymbolsSize*2
390 dv.codeWidth = remainingWidth / 2
391 dv.extraColOnAfter = isOdd(remainingWidth)
394 dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
397// renderUnified renders the unified diff view as a string.
398func (dv *DiffView) renderUnified() string {
399 var b strings.Builder
401 fullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
402 printedLines := -dv.yOffset
403 shouldWrite := func() bool { return printedLines >= 0 }
405 getContent := func(in string, ls LineStyle) (content string, leadingEllipsis bool) {
406 content = strings.TrimSuffix(in, "\n")
407 content = dv.hightlightCode(content, ls.Code.GetBackground())
408 content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
409 content = ansi.Truncate(content, dv.codeWidth, "…")
410 leadingEllipsis = dv.xOffset > 0 && strings.TrimSpace(content) != ""
415 for i, h := range dv.unified.Hunks {
417 ls := dv.style.DividerLine
419 b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
420 b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
422 content := ansi.Truncate(dv.hunkLineFor(h), dv.fullCodeWidth, "…")
423 b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(content))
428 beforeLine := h.FromLine
429 afterLine := h.ToLine
431 for j, l := range h.Lines {
432 // print ellipis if we don't have enough space to print the rest of the diff
433 hasReachedHeight := dv.height > 0 && printedLines+1 == dv.height
434 isLastHunk := i+1 == len(dv.unified.Hunks)
435 isLastLine := j+1 == len(h.Lines)
436 if hasReachedHeight && (!isLastHunk || !isLastLine) {
438 ls := dv.lineStyleForType(l.Kind)
440 b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
441 b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
443 b.WriteString(fullContentStyle.Render(
444 ls.Code.Width(dv.fullCodeWidth).Render(" …"),
454 ls := dv.style.EqualLine
455 content, leadingEllipsis := getContent(l.Content, ls)
457 b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
458 b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
460 b.WriteString(fullContentStyle.Render(
461 ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", " ") + content),
468 ls := dv.style.InsertLine
469 content, leadingEllipsis := getContent(l.Content, ls)
471 b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
472 b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
474 b.WriteString(fullContentStyle.Render(
475 ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
476 ls.Code.Width(dv.codeWidth).Render(content),
482 ls := dv.style.DeleteLine
483 content, leadingEllipsis := getContent(l.Content, ls)
485 b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
486 b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
488 b.WriteString(fullContentStyle.Render(
489 ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
490 ls.Code.Width(dv.codeWidth).Render(content),
503 for printedLines < dv.height {
505 ls := dv.style.MissingLine
507 b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
508 b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
510 b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(" "))
519// renderSplit renders the split (side-by-side) diff view as a string.
520func (dv *DiffView) renderSplit() string {
521 var b strings.Builder
523 beforeFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
524 afterFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth + btoi(dv.extraColOnAfter))
525 printedLines := -dv.yOffset
526 shouldWrite := func() bool { return printedLines >= 0 }
528 getContent := func(in string, ls LineStyle) (content string, leadingEllipsis bool) {
529 content = strings.TrimSuffix(in, "\n")
530 content = dv.hightlightCode(content, ls.Code.GetBackground())
531 content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
532 content = ansi.Truncate(content, dv.codeWidth, "…")
533 leadingEllipsis = dv.xOffset > 0 && strings.TrimSpace(content) != ""
538 for i, h := range dv.splitHunks {
540 ls := dv.style.DividerLine
542 b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
544 content := ansi.Truncate(dv.hunkLineFor(dv.unified.Hunks[i]), dv.fullCodeWidth, "…")
545 b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(content))
547 b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
549 b.WriteString(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
554 beforeLine := h.fromLine
555 afterLine := h.toLine
557 for j, l := range h.lines {
558 // print ellipis if we don't have enough space to print the rest of the diff
559 hasReachedHeight := dv.height > 0 && printedLines+1 == dv.height
560 isLastHunk := i+1 == len(dv.unified.Hunks)
561 isLastLine := j+1 == len(h.lines)
562 if hasReachedHeight && (!isLastHunk || !isLastLine) {
564 ls := dv.style.MissingLine
566 ls = dv.lineStyleForType(l.before.Kind)
569 b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
571 b.WriteString(beforeFullContentStyle.Render(
572 ls.Code.Width(dv.fullCodeWidth).Render(" …"),
574 ls = dv.style.MissingLine
576 ls = dv.lineStyleForType(l.after.Kind)
579 b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
581 b.WriteString(afterFullContentStyle.Render(
582 ls.Code.Width(dv.fullCodeWidth).Render(" …"),
590 case l.before == nil:
592 ls := dv.style.MissingLine
594 b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
596 b.WriteString(beforeFullContentStyle.Render(
597 ls.Code.Width(dv.fullCodeWidth).Render(" "),
600 case l.before.Kind == udiff.Equal:
602 ls := dv.style.EqualLine
603 content, leadingEllipsis := getContent(l.before.Content, ls)
605 b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
607 b.WriteString(beforeFullContentStyle.Render(
608 ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", " ") + content),
612 case l.before.Kind == udiff.Delete:
614 ls := dv.style.DeleteLine
615 content, leadingEllipsis := getContent(l.before.Content, ls)
617 b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
619 b.WriteString(beforeFullContentStyle.Render(
620 ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
621 ls.Code.Width(dv.codeWidth).Render(content),
630 ls := dv.style.MissingLine
632 b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
634 b.WriteString(afterFullContentStyle.Render(
635 ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "),
638 case l.after.Kind == udiff.Equal:
640 ls := dv.style.EqualLine
641 content, leadingEllipsis := getContent(l.after.Content, ls)
643 b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
645 b.WriteString(afterFullContentStyle.Render(
646 ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(ternary(leadingEllipsis, " …", " ") + content),
650 case l.after.Kind == udiff.Insert:
652 ls := dv.style.InsertLine
653 content, leadingEllipsis := getContent(l.after.Content, ls)
655 b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
657 b.WriteString(afterFullContentStyle.Render(
658 ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
659 ls.Code.Width(dv.codeWidth+btoi(dv.extraColOnAfter)).Render(content),
673 for printedLines < dv.height {
675 ls := dv.style.MissingLine
677 b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
679 b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(" "))
681 b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
683 b.WriteString(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
692// hunkLineFor formats the header line for a hunk in the unified diff view.
693func (dv *DiffView) hunkLineFor(h *udiff.Hunk) string {
694 beforeShownLines, afterShownLines := dv.hunkShownLines(h)
697 " @@ -%d,%d +%d,%d @@ ",
705// hunkShownLines calculates the number of lines shown in a hunk for both before
706// and after versions.
707func (dv *DiffView) hunkShownLines(h *udiff.Hunk) (before, after int) {
708 for _, l := range h.Lines {
722func (dv *DiffView) lineStyleForType(t udiff.OpKind) LineStyle {
725 return dv.style.EqualLine
727 return dv.style.InsertLine
729 return dv.style.DeleteLine
731 return dv.style.MissingLine
735func (dv *DiffView) hightlightCode(source string, bgColor color.Color) string {
736 if dv.chromaStyle == nil {
740 // Create cache key from content and background color
741 cacheKey := dv.createSyntaxCacheKey(source, bgColor)
743 // Check if we already have this highlighted
744 if cached, exists := dv.syntaxCache[cacheKey]; exists {
748 l := dv.getChromaLexer()
749 f := dv.getChromaFormatter(bgColor)
751 it, err := l.Tokenise(nil, source)
756 var b strings.Builder
757 if err := f.Format(&b, dv.chromaStyle, it); err != nil {
763 // Cache the result for future use
764 dv.syntaxCache[cacheKey] = result
769// createSyntaxCacheKey creates a cache key from source content and background color.
770// We use a simple hash to keep memory usage reasonable.
771func (dv *DiffView) createSyntaxCacheKey(source string, bgColor color.Color) string {
772 // Convert color to string representation
773 r, g, b, a := bgColor.RGBA()
774 colorStr := fmt.Sprintf("%d,%d,%d,%d", r, g, b, a)
776 // Create a hash of the content + color to use as cache key
778 h.Write([]byte(source))
779 h.Write([]byte(colorStr))
780 return fmt.Sprintf("%x", h.Sum(nil))
783func (dv *DiffView) getChromaLexer() chroma.Lexer {
784 if dv.cachedLexer != nil {
785 return dv.cachedLexer
788 l := lexers.Match(dv.before.path)
790 l = lexers.Analyse(dv.before.content)
795 dv.cachedLexer = chroma.Coalesce(l)
796 return dv.cachedLexer
799func (dv *DiffView) getChromaFormatter(bgColor color.Color) chroma.Formatter {
800 return chromaFormatter{
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