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"
17 leadingSymbolsSize = 2
29 layoutUnified layout = iota + 1
33// DiffView represents a view for displaying differences between two files.
47 chromaStyle *chroma.Style
51 unified udiff.UnifiedDiff
54 splitHunks []splitHunk
58 fullCodeWidth int // with leading symbols
59 extraColOnAfter bool // add extra column on after panel
64// New creates a new DiffView with default settings.
67 layout: layoutUnified,
68 contextLines: udiff.DefaultContextLines,
72 dv.style = DefaultDarkStyle()
76// Unified sets the layout of the DiffView to unified.
77func (dv *DiffView) Unified() *DiffView {
78 dv.layout = layoutUnified
82// Split sets the layout of the DiffView to split (side-by-side).
83func (dv *DiffView) Split() *DiffView {
84 dv.layout = layoutSplit
88// Before sets the "before" file for the DiffView.
89func (dv *DiffView) Before(path, content string) *DiffView {
90 dv.before = file{path: path, content: content}
94// After sets the "after" file for the DiffView.
95func (dv *DiffView) After(path, content string) *DiffView {
96 dv.after = file{path: path, content: content}
100// ContextLines sets the number of context lines for the DiffView.
101func (dv *DiffView) ContextLines(contextLines int) *DiffView {
102 dv.contextLines = contextLines
106// Style sets the style for the DiffView.
107func (dv *DiffView) Style(style Style) *DiffView {
112// LineNumbers sets whether to display line numbers in the DiffView.
113func (dv *DiffView) LineNumbers(lineNumbers bool) *DiffView {
114 dv.lineNumbers = lineNumbers
118// Height sets the height of the DiffView.
119func (dv *DiffView) Height(height int) *DiffView {
124// Width sets the width of the DiffView.
125func (dv *DiffView) Width(width int) *DiffView {
130// XOffset sets the horizontal offset for the DiffView.
131func (dv *DiffView) XOffset(xOffset int) *DiffView {
136// YOffset sets the vertical offset for the DiffView.
137func (dv *DiffView) YOffset(yOffset int) *DiffView {
142// InfiniteYScroll allows the YOffset to scroll beyond the last line.
143func (dv *DiffView) InfiniteYScroll(infiniteYScroll bool) *DiffView {
144 dv.infiniteYScroll = infiniteYScroll
148// TabWidth sets the tab width. Only relevant for code that contains tabs, like
150func (dv *DiffView) TabWidth(tabWidth int) *DiffView {
151 dv.tabWidth = tabWidth
155// ChromaStyle sets the chroma style for syntax highlighting.
156// If nil, no syntax highlighting will be applied.
157func (dv *DiffView) ChromaStyle(style *chroma.Style) *DiffView {
158 dv.chromaStyle = style
162// String returns the string representation of the DiffView.
163func (dv *DiffView) String() string {
165 if err := dv.computeDiff(); err != nil {
168 dv.convertDiffToSplit()
171 dv.detectTotalLines()
172 dv.preventInfiniteYScroll()
180 style := lipgloss.NewStyle()
182 style = style.MaxWidth(dv.width)
185 style = style.MaxHeight(dv.height)
190 return style.Render(strings.TrimSuffix(dv.renderUnified(), "\n"))
192 return style.Render(strings.TrimSuffix(dv.renderSplit(), "\n"))
194 panic("unknown diffview layout")
198// replaceTabs replaces tabs in the before and after file contents with spaces
199// according to the specified tab width.
200func (dv *DiffView) replaceTabs() {
201 spaces := strings.Repeat(" ", dv.tabWidth)
202 dv.before.content = strings.ReplaceAll(dv.before.content, "\t", spaces)
203 dv.after.content = strings.ReplaceAll(dv.after.content, "\t", spaces)
206// computeDiff computes the differences between the "before" and "after" files.
207func (dv *DiffView) computeDiff() error {
212 dv.edits = udiff.Strings(
216 dv.unified, dv.err = udiff.ToUnifiedDiff(
226// convertDiffToSplit converts the unified diff to a split diff if the layout is
228func (dv *DiffView) convertDiffToSplit() {
229 if dv.layout != layoutSplit {
233 dv.splitHunks = make([]splitHunk, len(dv.unified.Hunks))
234 for i, h := range dv.unified.Hunks {
235 dv.splitHunks[i] = hunkToSplit(h)
239// adjustStyles adjusts adds padding and alignment to the styles.
240func (dv *DiffView) adjustStyles() {
241 setPadding := func(s lipgloss.Style) lipgloss.Style {
242 return s.Padding(0, lineNumPadding).Align(lipgloss.Right)
244 dv.style.MissingLine.LineNumber = setPadding(dv.style.MissingLine.LineNumber)
245 dv.style.DividerLine.LineNumber = setPadding(dv.style.DividerLine.LineNumber)
246 dv.style.EqualLine.LineNumber = setPadding(dv.style.EqualLine.LineNumber)
247 dv.style.InsertLine.LineNumber = setPadding(dv.style.InsertLine.LineNumber)
248 dv.style.DeleteLine.LineNumber = setPadding(dv.style.DeleteLine.LineNumber)
251// detectNumDigits calculates the maximum number of digits needed for before and
252// after line numbers.
253func (dv *DiffView) detectNumDigits() {
254 dv.beforeNumDigits = 0
255 dv.afterNumDigits = 0
257 for _, h := range dv.unified.Hunks {
258 dv.beforeNumDigits = max(dv.beforeNumDigits, len(strconv.Itoa(h.FromLine+len(h.Lines))))
259 dv.afterNumDigits = max(dv.afterNumDigits, len(strconv.Itoa(h.ToLine+len(h.Lines))))
263func (dv *DiffView) detectTotalLines() {
268 for _, h := range dv.unified.Hunks {
269 dv.totalLines += 1 + len(h.Lines)
272 for _, h := range dv.splitHunks {
273 dv.totalLines += 1 + len(h.lines)
278func (dv *DiffView) preventInfiniteYScroll() {
279 if dv.infiniteYScroll {
283 // clamp yOffset to prevent scrolling beyond the last line
285 maxYOffset := max(0, dv.totalLines-dv.height)
286 dv.yOffset = min(dv.yOffset, maxYOffset)
288 // if no height limit, ensure yOffset doesn't exceed total lines
289 dv.yOffset = min(dv.yOffset, max(0, dv.totalLines-1))
291 dv.yOffset = max(0, dv.yOffset) // ensure yOffset is not negative
294// detectCodeWidth calculates the maximum width of code lines in the diff view.
295func (dv *DiffView) detectCodeWidth() {
298 dv.detectUnifiedCodeWidth()
300 dv.detectSplitCodeWidth()
302 dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
305// detectUnifiedCodeWidth calculates the maximum width of code lines in a
307func (dv *DiffView) detectUnifiedCodeWidth() {
310 for _, h := range dv.unified.Hunks {
311 shownLines := ansi.StringWidth(dv.hunkLineFor(h))
313 for _, l := range h.Lines {
314 lineWidth := ansi.StringWidth(strings.TrimSuffix(l.Content, "\n")) + 1
315 dv.codeWidth = max(dv.codeWidth, lineWidth, shownLines)
320// detectSplitCodeWidth calculates the maximum width of code lines in a
322func (dv *DiffView) detectSplitCodeWidth() {
325 for i, h := range dv.splitHunks {
326 shownLines := ansi.StringWidth(dv.hunkLineFor(dv.unified.Hunks[i]))
328 for _, l := range h.lines {
330 codeWidth := ansi.StringWidth(strings.TrimSuffix(l.before.Content, "\n")) + 1
331 dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
334 codeWidth := ansi.StringWidth(strings.TrimSuffix(l.after.Content, "\n")) + 1
335 dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
341// resizeCodeWidth resizes the code width to fit within the specified width.
342func (dv *DiffView) resizeCodeWidth() {
343 fullNumWidth := dv.beforeNumDigits + dv.afterNumDigits
344 fullNumWidth += lineNumPadding * 4 // left and right padding for both line numbers
348 dv.codeWidth = dv.width - fullNumWidth - leadingSymbolsSize
350 remainingWidth := dv.width - fullNumWidth - leadingSymbolsSize*2
351 dv.codeWidth = remainingWidth / 2
352 dv.extraColOnAfter = isOdd(remainingWidth)
355 dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
358// renderUnified renders the unified diff view as a string.
359func (dv *DiffView) renderUnified() string {
360 var b strings.Builder
362 fullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
363 printedLines := -dv.yOffset
364 shouldWrite := func() bool { return printedLines >= 0 }
366 getContent := func(in string, ls LineStyle) (content string, leadingEllipsis bool) {
367 content = strings.ReplaceAll(in, "\r\n", "\n")
368 content = strings.TrimSuffix(content, "\n")
369 content = dv.hightlightCode(content, ls.Code.GetBackground())
370 content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
371 content = ansi.Truncate(content, dv.codeWidth, "…")
372 leadingEllipsis = dv.xOffset > 0 && strings.TrimSpace(content) != ""
377 for i, h := range dv.unified.Hunks {
379 ls := dv.style.DividerLine
381 b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
382 b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
384 content := ansi.Truncate(dv.hunkLineFor(h), dv.fullCodeWidth, "…")
385 b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(content))
390 beforeLine := h.FromLine
391 afterLine := h.ToLine
393 for j, l := range h.Lines {
394 // print ellipis if we don't have enough space to print the rest of the diff
395 hasReachedHeight := dv.height > 0 && printedLines+1 == dv.height
396 isLastHunk := i+1 == len(dv.unified.Hunks)
397 isLastLine := j+1 == len(h.Lines)
398 if hasReachedHeight && (!isLastHunk || !isLastLine) {
400 ls := dv.lineStyleForType(l.Kind)
402 b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
403 b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
405 b.WriteString(fullContentStyle.Render(
406 ls.Code.Width(dv.fullCodeWidth).Render(" …"),
416 ls := dv.style.EqualLine
417 content, leadingEllipsis := getContent(l.Content, ls)
419 b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
420 b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
422 b.WriteString(fullContentStyle.Render(
423 ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", " ") + content),
430 ls := dv.style.InsertLine
431 content, leadingEllipsis := getContent(l.Content, ls)
433 b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
434 b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
436 b.WriteString(fullContentStyle.Render(
437 ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
438 ls.Code.Width(dv.codeWidth).Render(content),
444 ls := dv.style.DeleteLine
445 content, leadingEllipsis := getContent(l.Content, ls)
447 b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
448 b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
450 b.WriteString(fullContentStyle.Render(
451 ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
452 ls.Code.Width(dv.codeWidth).Render(content),
465 for printedLines < dv.height {
467 ls := dv.style.MissingLine
469 b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
470 b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
472 b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(" "))
481// renderSplit renders the split (side-by-side) diff view as a string.
482func (dv *DiffView) renderSplit() string {
483 var b strings.Builder
485 beforeFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
486 afterFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth + btoi(dv.extraColOnAfter))
487 printedLines := -dv.yOffset
488 shouldWrite := func() bool { return printedLines >= 0 }
490 getContent := func(in string, ls LineStyle) (content string, leadingEllipsis bool) {
491 content = strings.ReplaceAll(in, "\r\n", "\n")
492 content = strings.TrimSuffix(content, "\n")
493 content = dv.hightlightCode(content, ls.Code.GetBackground())
494 content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
495 content = ansi.Truncate(content, dv.codeWidth, "…")
496 leadingEllipsis = dv.xOffset > 0 && strings.TrimSpace(content) != ""
501 for i, h := range dv.splitHunks {
503 ls := dv.style.DividerLine
505 b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
507 content := ansi.Truncate(dv.hunkLineFor(dv.unified.Hunks[i]), dv.fullCodeWidth, "…")
508 b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(content))
510 b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
512 b.WriteString(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
517 beforeLine := h.fromLine
518 afterLine := h.toLine
520 for j, l := range h.lines {
521 // print ellipis if we don't have enough space to print the rest of the diff
522 hasReachedHeight := dv.height > 0 && printedLines+1 == dv.height
523 isLastHunk := i+1 == len(dv.unified.Hunks)
524 isLastLine := j+1 == len(h.lines)
525 if hasReachedHeight && (!isLastHunk || !isLastLine) {
527 ls := dv.style.MissingLine
529 ls = dv.lineStyleForType(l.before.Kind)
532 b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
534 b.WriteString(beforeFullContentStyle.Render(
535 ls.Code.Width(dv.fullCodeWidth).Render(" …"),
537 ls = dv.style.MissingLine
539 ls = dv.lineStyleForType(l.after.Kind)
542 b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
544 b.WriteString(afterFullContentStyle.Render(
545 ls.Code.Width(dv.fullCodeWidth).Render(" …"),
553 case l.before == nil:
555 ls := dv.style.MissingLine
557 b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
559 b.WriteString(beforeFullContentStyle.Render(
560 ls.Code.Width(dv.fullCodeWidth).Render(" "),
563 case l.before.Kind == udiff.Equal:
565 ls := dv.style.EqualLine
566 content, leadingEllipsis := getContent(l.before.Content, ls)
568 b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
570 b.WriteString(beforeFullContentStyle.Render(
571 ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", " ") + content),
575 case l.before.Kind == udiff.Delete:
577 ls := dv.style.DeleteLine
578 content, leadingEllipsis := getContent(l.before.Content, ls)
580 b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
582 b.WriteString(beforeFullContentStyle.Render(
583 ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
584 ls.Code.Width(dv.codeWidth).Render(content),
593 ls := dv.style.MissingLine
595 b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
597 b.WriteString(afterFullContentStyle.Render(
598 ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "),
601 case l.after.Kind == udiff.Equal:
603 ls := dv.style.EqualLine
604 content, leadingEllipsis := getContent(l.after.Content, ls)
606 b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
608 b.WriteString(afterFullContentStyle.Render(
609 ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(ternary(leadingEllipsis, " …", " ") + content),
613 case l.after.Kind == udiff.Insert:
615 ls := dv.style.InsertLine
616 content, leadingEllipsis := getContent(l.after.Content, ls)
618 b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
620 b.WriteString(afterFullContentStyle.Render(
621 ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
622 ls.Code.Width(dv.codeWidth+btoi(dv.extraColOnAfter)).Render(content),
636 for printedLines < dv.height {
638 ls := dv.style.MissingLine
640 b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
642 b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(" "))
644 b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
646 b.WriteString(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
655// hunkLineFor formats the header line for a hunk in the unified diff view.
656func (dv *DiffView) hunkLineFor(h *udiff.Hunk) string {
657 beforeShownLines, afterShownLines := dv.hunkShownLines(h)
660 " @@ -%d,%d +%d,%d @@ ",
668// hunkShownLines calculates the number of lines shown in a hunk for both before
669// and after versions.
670func (dv *DiffView) hunkShownLines(h *udiff.Hunk) (before, after int) {
671 for _, l := range h.Lines {
685func (dv *DiffView) lineStyleForType(t udiff.OpKind) LineStyle {
688 return dv.style.EqualLine
690 return dv.style.InsertLine
692 return dv.style.DeleteLine
694 return dv.style.MissingLine
698func (dv *DiffView) hightlightCode(source string, bgColor color.Color) string {
699 if dv.chromaStyle == nil {
703 l := dv.getChromaLexer(source)
704 f := dv.getChromaFormatter(bgColor)
706 it, err := l.Tokenise(nil, source)
711 var b strings.Builder
712 if err := f.Format(&b, dv.chromaStyle, it); err != nil {
718func (dv *DiffView) getChromaLexer(source string) chroma.Lexer {
719 l := lexers.Match(dv.before.path)
721 l = lexers.Analyse(source)
726 return chroma.Coalesce(l)
729func (dv *DiffView) getChromaFormatter(gbColor color.Color) chroma.Formatter {
730 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