diffview.go

  1package diffview
  2
  3import (
  4	"os"
  5	"strings"
  6
  7	"github.com/aymanbagabas/go-udiff"
  8	"github.com/aymanbagabas/go-udiff/myers"
  9	"github.com/charmbracelet/lipgloss/v2"
 10	"github.com/charmbracelet/x/ansi"
 11	"github.com/charmbracelet/x/exp/charmtone"
 12)
 13
 14const leadingSymbolsSize = 2
 15
 16type file struct {
 17	path    string
 18	content string
 19}
 20
 21type layout int
 22
 23const (
 24	layoutUnified layout = iota + 1
 25	layoutSplit
 26)
 27
 28type Style struct {
 29	Base          lipgloss.Style
 30	InsertLine    lipgloss.Style
 31	InsertSymbols lipgloss.Style
 32	DeleteLine    lipgloss.Style
 33	DeleteSymbols lipgloss.Style
 34}
 35
 36var DefaultLightStyle = Style{
 37	Base: lipgloss.NewStyle().
 38		Foreground(charmtone.Pepper).
 39		Background(charmtone.Salt),
 40	InsertLine: lipgloss.NewStyle().
 41		Foreground(charmtone.Pepper).
 42		Background(lipgloss.Color("#e8f5e9")),
 43	InsertSymbols: lipgloss.NewStyle().
 44		Foreground(charmtone.Turtle).
 45		Background(lipgloss.Color("#e8f5e9")),
 46	DeleteLine: lipgloss.NewStyle().
 47		Foreground(charmtone.Pepper).
 48		Background(lipgloss.Color("#ffebee")),
 49	DeleteSymbols: lipgloss.NewStyle().
 50		Foreground(charmtone.Cherry).
 51		Background(lipgloss.Color("#ffebee")),
 52}
 53
 54var DefaultDarkStyle = Style{
 55	Base: lipgloss.NewStyle().
 56		Foreground(charmtone.Salt).
 57		Background(charmtone.Pepper),
 58	InsertLine: lipgloss.NewStyle().
 59		Foreground(charmtone.Salt).
 60		Background(lipgloss.Color("#303a30")),
 61	InsertSymbols: lipgloss.NewStyle().
 62		Foreground(charmtone.Turtle).
 63		Background(lipgloss.Color("#303a30")),
 64	DeleteLine: lipgloss.NewStyle().
 65		Foreground(charmtone.Salt).
 66		Background(lipgloss.Color("#3a3030")),
 67	DeleteSymbols: lipgloss.NewStyle().
 68		Foreground(charmtone.Cherry).
 69		Background(lipgloss.Color("#3a3030")),
 70}
 71
 72// DiffView represents a view for displaying differences between two files.
 73type DiffView struct {
 74	layout       layout
 75	before       file
 76	after        file
 77	contextLines int
 78	highlight    bool
 79	height       int
 80	width        int
 81	style        Style
 82
 83	isComputed bool
 84	err        error
 85	unified    udiff.UnifiedDiff
 86	edits      []udiff.Edit
 87}
 88
 89// New creates a new DiffView with default settings.
 90func New() *DiffView {
 91	dv := &DiffView{
 92		layout:       layoutUnified,
 93		contextLines: udiff.DefaultContextLines,
 94	}
 95	if lipgloss.HasDarkBackground(os.Stdin, os.Stdout) {
 96		dv.style = DefaultDarkStyle
 97	} else {
 98		dv.style = DefaultLightStyle
 99	}
100	return dv
101}
102
103// Unified sets the layout of the DiffView to unified.
104func (dv *DiffView) Unified() *DiffView {
105	dv.layout = layoutUnified
106	return dv
107}
108
109// Split sets the layout of the DiffView to split (side-by-side).
110func (dv *DiffView) Split() *DiffView {
111	dv.layout = layoutSplit
112	return dv
113}
114
115// Before sets the "before" file for the DiffView.
116func (dv *DiffView) Before(path, content string) *DiffView {
117	dv.before = file{path: path, content: content}
118	return dv
119}
120
121// After sets the "after" file for the DiffView.
122func (dv *DiffView) After(path, content string) *DiffView {
123	dv.after = file{path: path, content: content}
124	return dv
125}
126
127// ContextLines sets the number of context lines for the DiffView.
128func (dv *DiffView) ContextLines(contextLines int) *DiffView {
129	dv.contextLines = contextLines
130	return dv
131}
132
133// Style sets the style for the DiffView.
134func (dv *DiffView) Style(style Style) *DiffView {
135	dv.style = style
136	return dv
137}
138
139// SyntaxHighlight sets whether to enable syntax highlighting in the DiffView.
140func (dv *DiffView) SyntaxHighlight(highlight bool) *DiffView {
141	dv.highlight = highlight
142	return dv
143}
144
145// Height sets the height of the DiffView.
146func (dv *DiffView) Height(height int) *DiffView {
147	dv.height = height
148	return dv
149}
150
151// Width sets the width of the DiffView.
152func (dv *DiffView) Width(width int) *DiffView {
153	dv.width = width
154	return dv
155}
156
157// String returns the string representation of the DiffView.
158func (dv *DiffView) String() string {
159	if err := dv.computeDiff(); err != nil {
160		return err.Error()
161	}
162	dv.detectWidth()
163
164	var b strings.Builder
165
166	for _, h := range dv.unified.Hunks {
167		for _, l := range h.Lines {
168			content := strings.TrimSuffix(l.Content, "\n")
169			width := dv.width - leadingSymbolsSize
170
171			switch l.Kind {
172			case udiff.Insert:
173				b.WriteString(dv.style.InsertSymbols.Render("+ "))
174				b.WriteString(dv.style.InsertLine.Width(width).Render(content))
175			case udiff.Delete:
176				b.WriteString(dv.style.DeleteSymbols.Render("- "))
177				b.WriteString(dv.style.DeleteLine.Width(width).Render(content))
178			case udiff.Equal:
179				b.WriteString(dv.style.Base.Width(width + leadingSymbolsSize).Render("  " + content))
180			}
181			b.WriteRune('\n')
182		}
183	}
184
185	return b.String()
186}
187
188func (dv *DiffView) computeDiff() error {
189	if dv.isComputed {
190		return dv.err
191	}
192	dv.isComputed = true
193	dv.edits = myers.ComputeEdits(
194		dv.before.content,
195		dv.after.content,
196	)
197	dv.unified, dv.err = udiff.ToUnifiedDiff(
198		dv.before.path,
199		dv.after.path,
200		dv.before.content,
201		dv.edits,
202		dv.contextLines,
203	)
204	return dv.err
205}
206
207func (dv *DiffView) detectWidth() {
208	if dv.width > 0 {
209		return
210	}
211
212	for _, h := range dv.unified.Hunks {
213		for _, l := range h.Lines {
214			lineWidth := ansi.StringWidth(strings.TrimSuffix(l.Content, "\n"))
215			lineWidth += leadingSymbolsSize
216			dv.width = max(dv.width, lineWidth)
217		}
218	}
219}