@@ -0,0 +1,681 @@
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.
35+type DiffView struct {
48+ chromaStyle *chroma.Style
52+ unified udiff.UnifiedDiff
55+ 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.
65+func New() *DiffView {
67+ layout: layoutUnified,
68+ contextLines: udiff.DefaultContextLines,
72+ dv.style = DefaultDarkStyle()
76+// Unified sets the layout of the DiffView to unified.
77+func (dv *DiffView) Unified() *DiffView {
78+ dv.layout = layoutUnified
82+// Split sets the layout of the DiffView to split (side-by-side).
83+func (dv *DiffView) Split() *DiffView {
84+ dv.layout = layoutSplit
88+// Before sets the "before" file for the DiffView.
89+func (dv *DiffView) Before(path, content string) *DiffView {
90+ dv.before = file{path: path, content: content}
94+// After sets the "after" file for the DiffView.
95+func (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.
101+func (dv *DiffView) ContextLines(contextLines int) *DiffView {
102+ dv.contextLines = contextLines
106+// Style sets the style for the DiffView.
107+func (dv *DiffView) Style(style Style) *DiffView {
112+// LineNumbers sets whether to display line numbers in the DiffView.
113+func (dv *DiffView) LineNumbers(lineNumbers bool) *DiffView {
114+ dv.lineNumbers = lineNumbers
118+// SyntaxHightlight sets whether to enable syntax highlighting in the DiffView.
119+func (dv *DiffView) SyntaxHightlight(highlight bool) *DiffView {
120+ dv.highlight = highlight
124+// Height sets the height of the DiffView.
125+func (dv *DiffView) Height(height int) *DiffView {
130+// Width sets the width of the DiffView.
131+func (dv *DiffView) Width(width int) *DiffView {
136+// XOffset sets the horizontal offset for the DiffView.
137+func (dv *DiffView) XOffset(xOffset int) *DiffView {
138+ dv.xOffset = xOffset
142+// YOffset sets the vertical offset for the DiffView.
143+func (dv *DiffView) YOffset(yOffset int) *DiffView {
144+ dv.yOffset = yOffset
148+// TabWidth sets the tab width. Only relevant for code that contains tabs, like
150+func (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.
157+func (dv *DiffView) ChromaStyle(style *chroma.Style) *DiffView {
158+ dv.chromaStyle = style
162+// String returns the string representation of the DiffView.
163+func (dv *DiffView) String() string {
165+ if err := dv.computeDiff(); err != nil {
168+ dv.convertDiffToSplit()
170+ dv.detectNumDigits()
173+ dv.detectCodeWidth()
175+ dv.resizeCodeWidth()
178+ style := lipgloss.NewStyle()
180+ style = style.MaxWidth(dv.width)
183+ style = style.MaxHeight(dv.height)
188+ return style.Render(strings.TrimSuffix(dv.renderUnified(), "\n"))
190+ return style.Render(strings.TrimSuffix(dv.renderSplit(), "\n"))
192+ panic("unknown diffview layout")
196+// replaceTabs replaces tabs in the before and after file contents with spaces
197+// according to the specified tab width.
198+func (dv *DiffView) replaceTabs() {
199+ spaces := strings.Repeat(" ", dv.tabWidth)
200+ dv.before.content = strings.ReplaceAll(dv.before.content, "\t", spaces)
201+ dv.after.content = strings.ReplaceAll(dv.after.content, "\t", spaces)
204+// computeDiff computes the differences between the "before" and "after" files.
205+func (dv *DiffView) computeDiff() error {
209+ dv.isComputed = true
210+ dv.edits = myers.ComputeEdits( //nolint:staticcheck
214+ dv.unified, dv.err = udiff.ToUnifiedDiff(
224+// convertDiffToSplit converts the unified diff to a split diff if the layout is
226+func (dv *DiffView) convertDiffToSplit() {
227+ if dv.layout != layoutSplit {
231+ dv.splitHunks = make([]splitHunk, len(dv.unified.Hunks))
232+ for i, h := range dv.unified.Hunks {
233+ dv.splitHunks[i] = hunkToSplit(h)
237+// adjustStyles adjusts adds padding and alignment to the styles.
238+func (dv *DiffView) adjustStyles() {
239+ setPadding := func(s lipgloss.Style) lipgloss.Style {
240+ return s.Padding(0, lineNumPadding).Align(lipgloss.Right)
242+ dv.style.MissingLine.LineNumber = setPadding(dv.style.MissingLine.LineNumber)
243+ dv.style.DividerLine.LineNumber = setPadding(dv.style.DividerLine.LineNumber)
244+ dv.style.EqualLine.LineNumber = setPadding(dv.style.EqualLine.LineNumber)
245+ dv.style.InsertLine.LineNumber = setPadding(dv.style.InsertLine.LineNumber)
246+ dv.style.DeleteLine.LineNumber = setPadding(dv.style.DeleteLine.LineNumber)
249+// detectNumDigits calculates the maximum number of digits needed for before and
250+// after line numbers.
251+func (dv *DiffView) detectNumDigits() {
252+ dv.beforeNumDigits = 0
253+ dv.afterNumDigits = 0
255+ for _, h := range dv.unified.Hunks {
256+ dv.beforeNumDigits = max(dv.beforeNumDigits, len(strconv.Itoa(h.FromLine+len(h.Lines))))
257+ dv.afterNumDigits = max(dv.afterNumDigits, len(strconv.Itoa(h.ToLine+len(h.Lines))))
261+// detectCodeWidth calculates the maximum width of code lines in the diff view.
262+func (dv *DiffView) detectCodeWidth() {
265+ dv.detectUnifiedCodeWidth()
267+ dv.detectSplitCodeWidth()
269+ dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
272+// detectUnifiedCodeWidth calculates the maximum width of code lines in a
274+func (dv *DiffView) detectUnifiedCodeWidth() {
277+ for _, h := range dv.unified.Hunks {
278+ shownLines := ansi.StringWidth(dv.hunkLineFor(h))
280+ for _, l := range h.Lines {
281+ lineWidth := ansi.StringWidth(strings.TrimSuffix(l.Content, "\n")) + 1
282+ dv.codeWidth = max(dv.codeWidth, lineWidth, shownLines)
287+// detectSplitCodeWidth calculates the maximum width of code lines in a
289+func (dv *DiffView) detectSplitCodeWidth() {
292+ for i, h := range dv.splitHunks {
293+ shownLines := ansi.StringWidth(dv.hunkLineFor(dv.unified.Hunks[i]))
295+ for _, l := range h.lines {
296+ if l.before != nil {
297+ codeWidth := ansi.StringWidth(strings.TrimSuffix(l.before.Content, "\n")) + 1
298+ dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
301+ codeWidth := ansi.StringWidth(strings.TrimSuffix(l.after.Content, "\n")) + 1
302+ dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
308+// resizeCodeWidth resizes the code width to fit within the specified width.
309+func (dv *DiffView) resizeCodeWidth() {
310+ fullNumWidth := dv.beforeNumDigits + dv.afterNumDigits
311+ fullNumWidth += lineNumPadding * 4 // left and right padding for both line numbers
315+ dv.codeWidth = dv.width - fullNumWidth - leadingSymbolsSize
317+ remainingWidth := dv.width - fullNumWidth - leadingSymbolsSize*2
318+ dv.codeWidth = remainingWidth / 2
319+ dv.extraColOnAfter = isOdd(remainingWidth)
322+ dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
325+// renderUnified renders the unified diff view as a string.
326+func (dv *DiffView) renderUnified() string {
327+ var b strings.Builder
329+ fullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
330+ printedLines := -dv.yOffset
332+ write := func(s string) {
333+ if printedLines >= 0 {
339+ for i, h := range dv.unified.Hunks {
340+ ls := dv.style.DividerLine
342+ write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
343+ write(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
345+ content := ansi.Truncate(dv.hunkLineFor(h), dv.fullCodeWidth, "…")
346+ write(ls.Code.Width(dv.fullCodeWidth).Render(content))
350+ beforeLine := h.FromLine
351+ afterLine := h.ToLine
353+ for j, l := range h.Lines {
354+ // print ellipis if we don't have enough space to print the rest of the diff
355+ hasReachedHeight := dv.height > 0 && printedLines+1 == dv.height
356+ isLastHunk := i+1 == len(dv.unified.Hunks)
357+ isLastLine := j+1 == len(h.Lines)
358+ if hasReachedHeight && (!isLastHunk || !isLastLine) {
359+ ls := dv.lineStyleForType(l.Kind)
361+ write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
362+ write(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
364+ write(fullContentStyle.Render(
365+ ls.Code.Width(dv.fullCodeWidth).Render(" …"),
371+ getContent := func(ls LineStyle) string {
372+ content := strings.TrimSuffix(l.Content, "\n")
373+ content = dv.hightlightCode(content, ls.Code.GetBackground())
374+ content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
375+ content = ansi.Truncate(content, dv.codeWidth, "…")
379+ leadingEllipsis := dv.xOffset > 0 && strings.TrimSpace(content) != ""
383+ ls := dv.style.EqualLine
384+ content := getContent(ls)
386+ write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
387+ write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
389+ write(fullContentStyle.Render(
390+ ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", " ") + content),
395+ ls := dv.style.InsertLine
396+ content := getContent(ls)
398+ write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
399+ write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
401+ write(fullContentStyle.Render(
402+ ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
403+ ls.Code.Width(dv.codeWidth).Render(content),
407+ ls := dv.style.DeleteLine
408+ content := getContent(ls)
410+ write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
411+ write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
413+ write(fullContentStyle.Render(
414+ ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
415+ ls.Code.Width(dv.codeWidth).Render(content),
425+ for printedLines < dv.height {
426+ ls := dv.style.MissingLine
428+ write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
429+ write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
431+ write(ls.Code.Width(dv.fullCodeWidth).Render(" "))
439+// renderSplit renders the split (side-by-side) diff view as a string.
440+func (dv *DiffView) renderSplit() string {
441+ var b strings.Builder
443+ beforeFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
444+ afterFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth + btoi(dv.extraColOnAfter))
445+ printedLines := -dv.yOffset
447+ write := func(s string) {
448+ if printedLines >= 0 {
454+ for i, h := range dv.splitHunks {
455+ ls := dv.style.DividerLine
457+ write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
459+ content := ansi.Truncate(dv.hunkLineFor(dv.unified.Hunks[i]), dv.fullCodeWidth, "…")
460+ write(ls.Code.Width(dv.fullCodeWidth).Render(content))
462+ write(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
464+ write(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
468+ beforeLine := h.fromLine
469+ afterLine := h.toLine
471+ for j, l := range h.lines {
472+ // print ellipis if we don't have enough space to print the rest of the diff
473+ hasReachedHeight := dv.height > 0 && printedLines+1 == dv.height
474+ isLastHunk := i+1 == len(dv.unified.Hunks)
475+ isLastLine := j+1 == len(h.lines)
476+ if hasReachedHeight && (!isLastHunk || !isLastLine) {
477+ ls := dv.style.MissingLine
478+ if l.before != nil {
479+ ls = dv.lineStyleForType(l.before.Kind)
482+ write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
484+ write(beforeFullContentStyle.Render(
485+ ls.Code.Width(dv.fullCodeWidth).Render(" …"),
487+ ls = dv.style.MissingLine
489+ ls = dv.lineStyleForType(l.after.Kind)
492+ write(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
494+ write(afterFullContentStyle.Render(
495+ ls.Code.Width(dv.fullCodeWidth).Render(" …"),
501+ getContent := func(content string, ls LineStyle) string {
502+ content = strings.TrimSuffix(content, "\n")
503+ content = dv.hightlightCode(content, ls.Code.GetBackground())
504+ content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
505+ content = ansi.Truncate(content, dv.codeWidth, "…")
508+ getLeadingEllipsis := func(content string) bool {
509+ return dv.xOffset > 0 && strings.TrimSpace(content) != ""
513+ case l.before == nil:
514+ ls := dv.style.MissingLine
516+ write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
518+ write(beforeFullContentStyle.Render(
519+ ls.Code.Width(dv.fullCodeWidth).Render(" "),
521+ case l.before.Kind == udiff.Equal:
522+ ls := dv.style.EqualLine
523+ content := getContent(l.before.Content, ls)
524+ leadingEllipsis := getLeadingEllipsis(content)
526+ write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
528+ write(beforeFullContentStyle.Render(
529+ ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", " ") + content),
532+ case l.before.Kind == udiff.Delete:
533+ ls := dv.style.DeleteLine
534+ content := getContent(l.before.Content, ls)
535+ leadingEllipsis := getLeadingEllipsis(content)
537+ write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
539+ write(beforeFullContentStyle.Render(
540+ ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
541+ ls.Code.Width(dv.codeWidth).Render(content),
547+ case l.after == nil:
548+ ls := dv.style.MissingLine
550+ write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
552+ write(afterFullContentStyle.Render(
553+ ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "),
555+ case l.after.Kind == udiff.Equal:
556+ ls := dv.style.EqualLine
557+ content := getContent(l.after.Content, ls)
558+ leadingEllipsis := getLeadingEllipsis(content)
560+ write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
562+ write(afterFullContentStyle.Render(
563+ ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(ternary(leadingEllipsis, " …", " ") + content),
566+ case l.after.Kind == udiff.Insert:
567+ ls := dv.style.InsertLine
568+ content := getContent(l.after.Content, ls)
569+ leadingEllipsis := getLeadingEllipsis(content)
571+ write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
573+ write(afterFullContentStyle.Render(
574+ ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
575+ ls.Code.Width(dv.codeWidth+btoi(dv.extraColOnAfter)).Render(content),
586+ for printedLines < dv.height {
587+ ls := dv.style.MissingLine
589+ write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
591+ write(ls.Code.Width(dv.fullCodeWidth).Render(" "))
593+ write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
595+ write(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
603+// hunkLineFor formats the header line for a hunk in the unified diff view.
604+func (dv *DiffView) hunkLineFor(h *udiff.Hunk) string {
605+ beforeShownLines, afterShownLines := dv.hunkShownLines(h)
608+ " @@ -%d,%d +%d,%d @@ ",
616+// hunkShownLines calculates the number of lines shown in a hunk for both before
617+// and after versions.
618+func (dv *DiffView) hunkShownLines(h *udiff.Hunk) (before, after int) {
619+ for _, l := range h.Lines {
633+func (dv *DiffView) lineStyleForType(t udiff.OpKind) LineStyle {
636+ return dv.style.EqualLine
638+ return dv.style.InsertLine
640+ return dv.style.DeleteLine
642+ return dv.style.MissingLine
646+func (dv *DiffView) hightlightCode(source string, bgColor color.Color) string {
647+ if dv.chromaStyle == nil {
651+ l := dv.getChromaLexer(source)
652+ f := dv.getChromaFormatter(bgColor)
654+ it, err := l.Tokenise(nil, source)
659+ var b strings.Builder
660+ if err := f.Format(&b, dv.chromaStyle, it); err != nil {
666+func (dv *DiffView) getChromaLexer(source string) chroma.Lexer {
667+ l := lexers.Match(dv.before.path)
669+ l = lexers.Analyse(source)
674+ return chroma.Coalesce(l)
677+func (dv *DiffView) getChromaFormatter(gbColor color.Color) chroma.Formatter {
678+ return chromaFormatter{