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/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) != ""
411 return content, leadingEllipsis
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),
506// renderSplit renders the split (side-by-side) diff view as a string.
507func (dv *DiffView) renderSplit() string {
508 var b strings.Builder
510 beforeFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
511 afterFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth + btoi(dv.extraColOnAfter))
512 printedLines := -dv.yOffset
513 shouldWrite := func() bool { return printedLines >= 0 }
515 getContent := func(in string, ls LineStyle) (content string, leadingEllipsis bool) {
516 content = strings.TrimSuffix(in, "\n")
517 content = dv.hightlightCode(content, ls.Code.GetBackground())
518 content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
519 content = ansi.Truncate(content, dv.codeWidth, "…")
520 leadingEllipsis = dv.xOffset > 0 && strings.TrimSpace(content) != ""
521 return content, leadingEllipsis
525 for i, h := range dv.splitHunks {
527 ls := dv.style.DividerLine
529 b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
531 content := ansi.Truncate(dv.hunkLineFor(dv.unified.Hunks[i]), dv.fullCodeWidth, "…")
532 b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(content))
534 b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
536 b.WriteString(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
541 beforeLine := h.fromLine
542 afterLine := h.toLine
544 for j, l := range h.lines {
545 // print ellipis if we don't have enough space to print the rest of the diff
546 hasReachedHeight := dv.height > 0 && printedLines+1 == dv.height
547 isLastHunk := i+1 == len(dv.unified.Hunks)
548 isLastLine := j+1 == len(h.lines)
549 if hasReachedHeight && (!isLastHunk || !isLastLine) {
551 ls := dv.style.MissingLine
553 ls = dv.lineStyleForType(l.before.Kind)
556 b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
558 b.WriteString(beforeFullContentStyle.Render(
559 ls.Code.Width(dv.fullCodeWidth).Render(" …"),
561 ls = dv.style.MissingLine
563 ls = dv.lineStyleForType(l.after.Kind)
566 b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
568 b.WriteString(afterFullContentStyle.Render(
569 ls.Code.Width(dv.fullCodeWidth).Render(" …"),
577 case l.before == nil:
579 ls := dv.style.MissingLine
581 b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
583 b.WriteString(beforeFullContentStyle.Render(
584 ls.Code.Width(dv.fullCodeWidth).Render(" "),
587 case l.before.Kind == udiff.Equal:
589 ls := dv.style.EqualLine
590 content, leadingEllipsis := getContent(l.before.Content, ls)
592 b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
594 b.WriteString(beforeFullContentStyle.Render(
595 ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", " ") + content),
599 case l.before.Kind == udiff.Delete:
601 ls := dv.style.DeleteLine
602 content, leadingEllipsis := getContent(l.before.Content, ls)
604 b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
606 b.WriteString(beforeFullContentStyle.Render(
607 ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
608 ls.Code.Width(dv.codeWidth).Render(content),
617 ls := dv.style.MissingLine
619 b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
621 b.WriteString(afterFullContentStyle.Render(
622 ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "),
625 case l.after.Kind == udiff.Equal:
627 ls := dv.style.EqualLine
628 content, leadingEllipsis := getContent(l.after.Content, ls)
630 b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
632 b.WriteString(afterFullContentStyle.Render(
633 ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(ternary(leadingEllipsis, " …", " ") + content),
637 case l.after.Kind == udiff.Insert:
639 ls := dv.style.InsertLine
640 content, leadingEllipsis := getContent(l.after.Content, ls)
642 b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
644 b.WriteString(afterFullContentStyle.Render(
645 ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
646 ls.Code.Width(dv.codeWidth+btoi(dv.extraColOnAfter)).Render(content),
663// hunkLineFor formats the header line for a hunk in the unified diff view.
664func (dv *DiffView) hunkLineFor(h *udiff.Hunk) string {
665 beforeShownLines, afterShownLines := dv.hunkShownLines(h)
668 " @@ -%d,%d +%d,%d @@ ",
676// hunkShownLines calculates the number of lines shown in a hunk for both before
677// and after versions.
678func (dv *DiffView) hunkShownLines(h *udiff.Hunk) (before, after int) {
679 for _, l := range h.Lines {
693func (dv *DiffView) lineStyleForType(t udiff.OpKind) LineStyle {
696 return dv.style.EqualLine
698 return dv.style.InsertLine
700 return dv.style.DeleteLine
702 return dv.style.MissingLine
706func (dv *DiffView) hightlightCode(source string, bgColor color.Color) string {
707 if dv.chromaStyle == nil {
711 // Create cache key from content and background color
712 cacheKey := dv.createSyntaxCacheKey(source, bgColor)
714 // Check if we already have this highlighted
715 if cached, exists := dv.syntaxCache[cacheKey]; exists {
719 l := dv.getChromaLexer()
720 f := dv.getChromaFormatter(bgColor)
722 it, err := l.Tokenise(nil, source)
727 var b strings.Builder
728 if err := f.Format(&b, dv.chromaStyle, it); err != nil {
734 // Cache the result for future use
735 dv.syntaxCache[cacheKey] = result
740// createSyntaxCacheKey creates a cache key from source content and background color.
741// We use a simple hash to keep memory usage reasonable.
742func (dv *DiffView) createSyntaxCacheKey(source string, bgColor color.Color) string {
743 // Convert color to string representation
744 r, g, b, a := bgColor.RGBA()
745 colorStr := fmt.Sprintf("%d,%d,%d,%d", r, g, b, a)
747 // Create a hash of the content + color to use as cache key
749 h.Write([]byte(source))
750 h.Write([]byte(colorStr))
751 return fmt.Sprintf("%x", h.Sum(nil))
754func (dv *DiffView) getChromaLexer() chroma.Lexer {
755 if dv.cachedLexer != nil {
756 return dv.cachedLexer
759 l := lexers.Match(dv.before.path)
761 l = lexers.Analyse(dv.before.content)
766 dv.cachedLexer = chroma.Coalesce(l)
767 return dv.cachedLexer
770func (dv *DiffView) getChromaFormatter(bgColor color.Color) chroma.Formatter {
771 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