9 "github.com/alecthomas/chroma/v2"
10 "github.com/alecthomas/chroma/v2/lexers"
11 "github.com/aymanbagabas/go-udiff"
12 "github.com/aymanbagabas/go-udiff/myers"
13 "github.com/charmbracelet/lipgloss/v2"
14 "github.com/charmbracelet/x/ansi"
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
65// New creates a new DiffView with default settings.
68 layout: layoutUnified,
69 contextLines: udiff.DefaultContextLines,
73 dv.style = DefaultDarkStyle()
77// Unified sets the layout of the DiffView to unified.
78func (dv *DiffView) Unified() *DiffView {
79 dv.layout = layoutUnified
83// Split sets the layout of the DiffView to split (side-by-side).
84func (dv *DiffView) Split() *DiffView {
85 dv.layout = layoutSplit
89// Before sets the "before" file for the DiffView.
90func (dv *DiffView) Before(path, content string) *DiffView {
91 dv.before = file{path: path, content: content}
95// After sets the "after" file for the DiffView.
96func (dv *DiffView) After(path, content string) *DiffView {
97 dv.after = file{path: path, content: content}
101// ContextLines sets the number of context lines for the DiffView.
102func (dv *DiffView) ContextLines(contextLines int) *DiffView {
103 dv.contextLines = contextLines
107// Style sets the style for the DiffView.
108func (dv *DiffView) Style(style Style) *DiffView {
113// LineNumbers sets whether to display line numbers in the DiffView.
114func (dv *DiffView) LineNumbers(lineNumbers bool) *DiffView {
115 dv.lineNumbers = lineNumbers
119// Height sets the height of the DiffView.
120func (dv *DiffView) Height(height int) *DiffView {
125// Width sets the width of the DiffView.
126func (dv *DiffView) Width(width int) *DiffView {
131// XOffset sets the horizontal offset for the DiffView.
132func (dv *DiffView) XOffset(xOffset int) *DiffView {
137// YOffset sets the vertical offset for the DiffView.
138func (dv *DiffView) YOffset(yOffset int) *DiffView {
143// InfiniteYScroll allows the YOffset to scroll beyond the last line.
144func (dv *DiffView) InfiniteYScroll(infiniteYScroll bool) *DiffView {
145 dv.infiniteYScroll = infiniteYScroll
149// TabWidth sets the tab width. Only relevant for code that contains tabs, like
151func (dv *DiffView) TabWidth(tabWidth int) *DiffView {
152 dv.tabWidth = tabWidth
156// ChromaStyle sets the chroma style for syntax highlighting.
157// If nil, no syntax highlighting will be applied.
158func (dv *DiffView) ChromaStyle(style *chroma.Style) *DiffView {
159 dv.chromaStyle = style
163// String returns the string representation of the DiffView.
164func (dv *DiffView) String() string {
166 if err := dv.computeDiff(); err != nil {
169 dv.convertDiffToSplit()
172 dv.detectTotalLines()
173 dv.preventInfiniteYScroll()
181 style := lipgloss.NewStyle()
183 style = style.MaxWidth(dv.width)
186 style = style.MaxHeight(dv.height)
191 return style.Render(strings.TrimSuffix(dv.renderUnified(), "\n"))
193 return style.Render(strings.TrimSuffix(dv.renderSplit(), "\n"))
195 panic("unknown diffview layout")
199// replaceTabs replaces tabs in the before and after file contents with spaces
200// according to the specified tab width.
201func (dv *DiffView) replaceTabs() {
202 spaces := strings.Repeat(" ", dv.tabWidth)
203 dv.before.content = strings.ReplaceAll(dv.before.content, "\t", spaces)
204 dv.after.content = strings.ReplaceAll(dv.after.content, "\t", spaces)
207// computeDiff computes the differences between the "before" and "after" files.
208func (dv *DiffView) computeDiff() error {
213 dv.edits = myers.ComputeEdits( //nolint:staticcheck
217 dv.unified, dv.err = udiff.ToUnifiedDiff(
227// convertDiffToSplit converts the unified diff to a split diff if the layout is
229func (dv *DiffView) convertDiffToSplit() {
230 if dv.layout != layoutSplit {
234 dv.splitHunks = make([]splitHunk, len(dv.unified.Hunks))
235 for i, h := range dv.unified.Hunks {
236 dv.splitHunks[i] = hunkToSplit(h)
240// adjustStyles adjusts adds padding and alignment to the styles.
241func (dv *DiffView) adjustStyles() {
242 setPadding := func(s lipgloss.Style) lipgloss.Style {
243 return s.Padding(0, lineNumPadding).Align(lipgloss.Right)
245 dv.style.MissingLine.LineNumber = setPadding(dv.style.MissingLine.LineNumber)
246 dv.style.DividerLine.LineNumber = setPadding(dv.style.DividerLine.LineNumber)
247 dv.style.EqualLine.LineNumber = setPadding(dv.style.EqualLine.LineNumber)
248 dv.style.InsertLine.LineNumber = setPadding(dv.style.InsertLine.LineNumber)
249 dv.style.DeleteLine.LineNumber = setPadding(dv.style.DeleteLine.LineNumber)
252// detectNumDigits calculates the maximum number of digits needed for before and
253// after line numbers.
254func (dv *DiffView) detectNumDigits() {
255 dv.beforeNumDigits = 0
256 dv.afterNumDigits = 0
258 for _, h := range dv.unified.Hunks {
259 dv.beforeNumDigits = max(dv.beforeNumDigits, len(strconv.Itoa(h.FromLine+len(h.Lines))))
260 dv.afterNumDigits = max(dv.afterNumDigits, len(strconv.Itoa(h.ToLine+len(h.Lines))))
264func (dv *DiffView) detectTotalLines() {
269 for _, h := range dv.unified.Hunks {
270 dv.totalLines += 1 + len(h.Lines)
273 for _, h := range dv.splitHunks {
274 dv.totalLines += 1 + len(h.lines)
279func (dv *DiffView) preventInfiniteYScroll() {
280 if dv.infiniteYScroll {
284 // clamp yOffset to prevent scrolling beyond the last line
286 maxYOffset := max(0, dv.totalLines-dv.height)
287 dv.yOffset = min(dv.yOffset, maxYOffset)
289 // if no height limit, ensure yOffset doesn't exceed total lines
290 dv.yOffset = min(dv.yOffset, max(0, dv.totalLines-1))
292 dv.yOffset = max(0, dv.yOffset) // ensure yOffset is not negative
295// detectCodeWidth calculates the maximum width of code lines in the diff view.
296func (dv *DiffView) detectCodeWidth() {
299 dv.detectUnifiedCodeWidth()
301 dv.detectSplitCodeWidth()
303 dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
306// detectUnifiedCodeWidth calculates the maximum width of code lines in a
308func (dv *DiffView) detectUnifiedCodeWidth() {
311 for _, h := range dv.unified.Hunks {
312 shownLines := ansi.StringWidth(dv.hunkLineFor(h))
314 for _, l := range h.Lines {
315 lineWidth := ansi.StringWidth(strings.TrimSuffix(l.Content, "\n")) + 1
316 dv.codeWidth = max(dv.codeWidth, lineWidth, shownLines)
321// detectSplitCodeWidth calculates the maximum width of code lines in a
323func (dv *DiffView) detectSplitCodeWidth() {
326 for i, h := range dv.splitHunks {
327 shownLines := ansi.StringWidth(dv.hunkLineFor(dv.unified.Hunks[i]))
329 for _, l := range h.lines {
331 codeWidth := ansi.StringWidth(strings.TrimSuffix(l.before.Content, "\n")) + 1
332 dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
335 codeWidth := ansi.StringWidth(strings.TrimSuffix(l.after.Content, "\n")) + 1
336 dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
342// resizeCodeWidth resizes the code width to fit within the specified width.
343func (dv *DiffView) resizeCodeWidth() {
344 fullNumWidth := dv.beforeNumDigits + dv.afterNumDigits
345 fullNumWidth += lineNumPadding * 4 // left and right padding for both line numbers
349 dv.codeWidth = dv.width - fullNumWidth - leadingSymbolsSize
351 remainingWidth := dv.width - fullNumWidth - leadingSymbolsSize*2
352 dv.codeWidth = remainingWidth / 2
353 dv.extraColOnAfter = isOdd(remainingWidth)
356 dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
359// renderUnified renders the unified diff view as a string.
360func (dv *DiffView) renderUnified() string {
361 var b strings.Builder
363 fullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
364 printedLines := -dv.yOffset
365 shouldWrite := func() bool { return printedLines >= 0 }
367 getContent := func(in string, ls LineStyle) (content string, leadingEllipsis bool) {
368 content = strings.TrimSuffix(in, "\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.TrimSuffix(in, "\n")
492 content = dv.hightlightCode(content, ls.Code.GetBackground())
493 content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
494 content = ansi.Truncate(content, dv.codeWidth, "…")
495 leadingEllipsis = dv.xOffset > 0 && strings.TrimSpace(content) != ""
500 for i, h := range dv.splitHunks {
502 ls := dv.style.DividerLine
504 b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
506 content := ansi.Truncate(dv.hunkLineFor(dv.unified.Hunks[i]), dv.fullCodeWidth, "…")
507 b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(content))
509 b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
511 b.WriteString(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
516 beforeLine := h.fromLine
517 afterLine := h.toLine
519 for j, l := range h.lines {
520 // print ellipis if we don't have enough space to print the rest of the diff
521 hasReachedHeight := dv.height > 0 && printedLines+1 == dv.height
522 isLastHunk := i+1 == len(dv.unified.Hunks)
523 isLastLine := j+1 == len(h.lines)
524 if hasReachedHeight && (!isLastHunk || !isLastLine) {
526 ls := dv.style.MissingLine
528 ls = dv.lineStyleForType(l.before.Kind)
531 b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
533 b.WriteString(beforeFullContentStyle.Render(
534 ls.Code.Width(dv.fullCodeWidth).Render(" …"),
536 ls = dv.style.MissingLine
538 ls = dv.lineStyleForType(l.after.Kind)
541 b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
543 b.WriteString(afterFullContentStyle.Render(
544 ls.Code.Width(dv.fullCodeWidth).Render(" …"),
552 case l.before == nil:
554 ls := dv.style.MissingLine
556 b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
558 b.WriteString(beforeFullContentStyle.Render(
559 ls.Code.Width(dv.fullCodeWidth).Render(" "),
562 case l.before.Kind == udiff.Equal:
564 ls := dv.style.EqualLine
565 content, leadingEllipsis := getContent(l.before.Content, ls)
567 b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
569 b.WriteString(beforeFullContentStyle.Render(
570 ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", " ") + content),
574 case l.before.Kind == udiff.Delete:
576 ls := dv.style.DeleteLine
577 content, leadingEllipsis := getContent(l.before.Content, ls)
579 b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
581 b.WriteString(beforeFullContentStyle.Render(
582 ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
583 ls.Code.Width(dv.codeWidth).Render(content),
592 ls := dv.style.MissingLine
594 b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
596 b.WriteString(afterFullContentStyle.Render(
597 ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "),
600 case l.after.Kind == udiff.Equal:
602 ls := dv.style.EqualLine
603 content, leadingEllipsis := getContent(l.after.Content, ls)
605 b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
607 b.WriteString(afterFullContentStyle.Render(
608 ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(ternary(leadingEllipsis, " …", " ") + content),
612 case l.after.Kind == udiff.Insert:
614 ls := dv.style.InsertLine
615 content, leadingEllipsis := getContent(l.after.Content, ls)
617 b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
619 b.WriteString(afterFullContentStyle.Render(
620 ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
621 ls.Code.Width(dv.codeWidth+btoi(dv.extraColOnAfter)).Render(content),
635 for printedLines < dv.height {
637 ls := dv.style.MissingLine
639 b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
641 b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(" "))
643 b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
645 b.WriteString(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
654// hunkLineFor formats the header line for a hunk in the unified diff view.
655func (dv *DiffView) hunkLineFor(h *udiff.Hunk) string {
656 beforeShownLines, afterShownLines := dv.hunkShownLines(h)
659 " @@ -%d,%d +%d,%d @@ ",
667// hunkShownLines calculates the number of lines shown in a hunk for both before
668// and after versions.
669func (dv *DiffView) hunkShownLines(h *udiff.Hunk) (before, after int) {
670 for _, l := range h.Lines {
684func (dv *DiffView) lineStyleForType(t udiff.OpKind) LineStyle {
687 return dv.style.EqualLine
689 return dv.style.InsertLine
691 return dv.style.DeleteLine
693 return dv.style.MissingLine
697func (dv *DiffView) hightlightCode(source string, bgColor color.Color) string {
698 if dv.chromaStyle == nil {
702 l := dv.getChromaLexer(source)
703 f := dv.getChromaFormatter(bgColor)
705 it, err := l.Tokenise(nil, source)
710 var b strings.Builder
711 if err := f.Format(&b, dv.chromaStyle, it); err != nil {
717func (dv *DiffView) getChromaLexer(source string) chroma.Lexer {
718 l := lexers.Match(dv.before.path)
720 l = lexers.Analyse(source)
725 return chroma.Coalesce(l)
728func (dv *DiffView) getChromaFormatter(gbColor color.Color) chroma.Formatter {
729 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