feat(diffview): basic working functionality

Andrey Nering created

Change summary

go.mod                                                      |   2 
go.sum                                                      |   2 
internal/exp/diffview/diffview.go                           | 118 ++++++
internal/exp/diffview/diffview_test.go                      |  22 +
internal/exp/diffview/testdata/TestDefault.after            |  10 
internal/exp/diffview/testdata/TestDefault.before           |   9 
internal/exp/diffview/testdata/TestDefault.golden           |   7 
internal/exp/diffview/testdata/TestDefault/DarkMode.golden  |   7 
internal/exp/diffview/testdata/TestDefault/LightMode.golden |   7 
9 files changed, 162 insertions(+), 22 deletions(-)

Detailed changes

go.mod 🔗

@@ -35,6 +35,8 @@ require (
 	github.com/stretchr/testify v1.10.0
 )
 
+require github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444
+
 require (
 	cloud.google.com/go v0.116.0 // indirect
 	cloud.google.com/go/auth v0.13.0 // indirect

go.sum 🔗

@@ -84,6 +84,8 @@ github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2ll
 github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
 github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
 github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
+github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0=
+github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
 github.com/charmbracelet/x/exp/golden v0.0.0-20250602192518-9e722df69bbb h1:GT/STWThMsrOfYQnhnIPb165e/g1waAp0gNMFvEO6WI=
 github.com/charmbracelet/x/exp/golden v0.0.0-20250602192518-9e722df69bbb/go.mod h1:929X+xY3LeoOZrDWIBVZcx/zyS0CYtyLiUIvE4VbKC0=
 github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=

internal/exp/diffview/diffview.go 🔗

@@ -1,11 +1,18 @@
 package diffview
 
 import (
+	"os"
+	"strings"
+
 	"github.com/aymanbagabas/go-udiff"
 	"github.com/aymanbagabas/go-udiff/myers"
 	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/x/ansi"
+	"github.com/charmbracelet/x/exp/charmtone"
 )
 
+const leadingSymbolsSize = 2
+
 type file struct {
 	path    string
 	content string
@@ -18,16 +25,60 @@ const (
 	layoutSplit
 )
 
+type Style struct {
+	Base          lipgloss.Style
+	InsertLine    lipgloss.Style
+	InsertSymbols lipgloss.Style
+	DeleteLine    lipgloss.Style
+	DeleteSymbols lipgloss.Style
+}
+
+var DefaultLightStyle = Style{
+	Base: lipgloss.NewStyle().
+		Foreground(charmtone.Pepper).
+		Background(charmtone.Salt),
+	InsertLine: lipgloss.NewStyle().
+		Foreground(charmtone.Pepper).
+		Background(lipgloss.Color("#e8f5e9")),
+	InsertSymbols: lipgloss.NewStyle().
+		Foreground(charmtone.Turtle).
+		Background(lipgloss.Color("#e8f5e9")),
+	DeleteLine: lipgloss.NewStyle().
+		Foreground(charmtone.Pepper).
+		Background(lipgloss.Color("#ffebee")),
+	DeleteSymbols: lipgloss.NewStyle().
+		Foreground(charmtone.Cherry).
+		Background(lipgloss.Color("#ffebee")),
+}
+
+var DefaultDarkStyle = Style{
+	Base: lipgloss.NewStyle().
+		Foreground(charmtone.Salt).
+		Background(charmtone.Pepper),
+	InsertLine: lipgloss.NewStyle().
+		Foreground(charmtone.Salt).
+		Background(lipgloss.Color("#303a30")),
+	InsertSymbols: lipgloss.NewStyle().
+		Foreground(charmtone.Turtle).
+		Background(lipgloss.Color("#303a30")),
+	DeleteLine: lipgloss.NewStyle().
+		Foreground(charmtone.Salt).
+		Background(lipgloss.Color("#3a3030")),
+	DeleteSymbols: lipgloss.NewStyle().
+		Foreground(charmtone.Cherry).
+		Background(lipgloss.Color("#3a3030")),
+}
+
 // DiffView represents a view for displaying differences between two files.
 type DiffView struct {
 	layout       layout
 	before       file
 	after        file
 	contextLines int
-	baseStyle    lipgloss.Style
 	highlight    bool
 	height       int
 	width        int
+	style        Style
 
 	isComputed bool
 	err        error
@@ -37,10 +88,16 @@ type DiffView struct {
 
 // New creates a new DiffView with default settings.
 func New() *DiffView {
-	return &DiffView{
+	dv := &DiffView{
 		layout:       layoutUnified,
 		contextLines: udiff.DefaultContextLines,
 	}
+	if lipgloss.HasDarkBackground(os.Stdin, os.Stdout) {
+		dv.style = DefaultDarkStyle
+	} else {
+		dv.style = DefaultLightStyle
+	}
+	return dv
 }
 
 // Unified sets the layout of the DiffView to unified.
@@ -73,10 +130,9 @@ func (dv *DiffView) ContextLines(contextLines int) *DiffView {
 	return dv
 }
 
-// BaseStyle sets the base style for the DiffView.
-// This is useful for setting a custom background color, for example.
-func (dv *DiffView) BaseStyle(baseStyle lipgloss.Style) *DiffView {
-	dv.baseStyle = baseStyle
+// Style sets the style for the DiffView.
+func (dv *DiffView) Style(style Style) *DiffView {
+	dv.style = style
 	return dv
 }
 
@@ -100,16 +156,39 @@ func (dv *DiffView) Width(width int) *DiffView {
 
 // String returns the string representation of the DiffView.
 func (dv *DiffView) String() string {
-	if !dv.isComputed {
-		dv.compute()
+	if err := dv.computeDiff(); err != nil {
+		return err.Error()
 	}
-	if dv.err != nil {
-		return dv.err.Error()
+	dv.detectWidth()
+
+	var b strings.Builder
+
+	for _, h := range dv.unified.Hunks {
+		for _, l := range h.Lines {
+			content := strings.TrimSuffix(l.Content, "\n")
+			width := dv.width - leadingSymbolsSize
+
+			switch l.Kind {
+			case udiff.Insert:
+				b.WriteString(dv.style.InsertSymbols.Render("+ "))
+				b.WriteString(dv.style.InsertLine.Width(width).Render(content))
+			case udiff.Delete:
+				b.WriteString(dv.style.DeleteSymbols.Render("- "))
+				b.WriteString(dv.style.DeleteLine.Width(width).Render(content))
+			case udiff.Equal:
+				b.WriteString(dv.style.Base.Width(width + leadingSymbolsSize).Render("  " + content))
+			}
+			b.WriteRune('\n')
+		}
 	}
-	return dv.unified.String()
+
+	return b.String()
 }
 
-func (dv *DiffView) compute() {
+func (dv *DiffView) computeDiff() error {
+	if dv.isComputed {
+		return dv.err
+	}
 	dv.isComputed = true
 	dv.edits = myers.ComputeEdits(
 		dv.before.content,
@@ -122,4 +201,19 @@ func (dv *DiffView) compute() {
 		dv.edits,
 		dv.contextLines,
 	)
+	return dv.err
+}
+
+func (dv *DiffView) detectWidth() {
+	if dv.width > 0 {
+		return
+	}
+
+	for _, h := range dv.unified.Hunks {
+		for _, l := range h.Lines {
+			lineWidth := ansi.StringWidth(strings.TrimSuffix(l.Content, "\n"))
+			lineWidth += leadingSymbolsSize
+			dv.width = max(dv.width, lineWidth)
+		}
+	}
 }

internal/exp/diffview/diffview_test.go 🔗

@@ -1,15 +1,31 @@
 package diffview_test
 
 import (
+	_ "embed"
 	"testing"
 
 	"github.com/charmbracelet/x/exp/golden"
 	"github.com/opencode-ai/opencode/internal/exp/diffview"
 )
 
+//go:embed testdata/TestDefault.before
+var TestDefaultBefore string
+
+//go:embed testdata/TestDefault.after
+var TestDefaultAfter string
+
 func TestDefault(t *testing.T) {
 	dv := diffview.New().
-		Before("test.txt", "This is the original content.").
-		After("test.txt", "This is the modified content.")
-	golden.RequireEqual(t, []byte(dv.String()))
+		Before("main.go", TestDefaultBefore).
+		After("main.go", TestDefaultAfter)
+
+	t.Run("LightMode", func(t *testing.T) {
+		dv = dv.Style(diffview.DefaultLightStyle)
+		golden.RequireEqual(t, []byte(dv.String()))
+	})
+
+	t.Run("DarkMode", func(t *testing.T) {
+		dv = dv.Style(diffview.DefaultDarkStyle)
+		golden.RequireEqual(t, []byte(dv.String()))
+	})
 }

internal/exp/diffview/testdata/TestDefault/DarkMode.golden 🔗

@@ -0,0 +1,7 @@
+  )                               
+                                  
+  func main() {                   
+-     fmt.Println("Hello, world!")
++     content := "Hello, world!"  
++     fmt.Println(content)        
+  }                               

internal/exp/diffview/testdata/TestDefault/LightMode.golden 🔗

@@ -0,0 +1,7 @@
+  )                               
+                                  
+  func main() {                   
+-     fmt.Println("Hello, world!")
++     content := "Hello, world!"  
++     fmt.Println(content)        
+  }