Detailed changes
@@ -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
@@ -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=
@@ -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)
+ }
+ }
}
@@ -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()))
+ })
}
@@ -0,0 +1,10 @@
+package main
+
+import (
+ "fmt"
+)
+
+func main() {
+ content := "Hello, world!"
+ fmt.Println(content)
+}
@@ -0,0 +1,9 @@
+package main
+
+import (
+ "fmt"
+)
+
+func main() {
+ fmt.Println("Hello, world!")
+}
@@ -1,7 +0,0 @@
---- test.txt
-+++ test.txt
-@@ -1 +1 @@
--This is the original content.
-\ No newline at end of file
-+This is the modified content.
-\ No newline at end of file
@@ -0,0 +1,7 @@
+[38;2;241;239;239;48;2;32;31;38m )[m[48;2;32;31;38m [m
+[38;2;241;239;239;48;2;32;31;38m [m[48;2;32;31;38m [m
+[38;2;241;239;239;48;2;32;31;38m func main() {[m[48;2;32;31;38m [m
+[38;2;255;56;139;48;2;58;48;48m- [m[38;2;241;239;239;48;2;58;48;48m fmt.Println("Hello, world!")[m
+[38;2;10;220;217;48;2;48;58;48m+ [m[38;2;241;239;239;48;2;48;58;48m content := "Hello, world!"[m[48;2;48;58;48m [m
+[38;2;10;220;217;48;2;48;58;48m+ [m[38;2;241;239;239;48;2;48;58;48m fmt.Println(content)[m[48;2;48;58;48m [m
+[38;2;241;239;239;48;2;32;31;38m }[m[48;2;32;31;38m [m
@@ -0,0 +1,7 @@
+[38;2;32;31;38;48;2;241;239;239m )[m[48;2;241;239;239m [m
+[38;2;32;31;38;48;2;241;239;239m [m[48;2;241;239;239m [m
+[38;2;32;31;38;48;2;241;239;239m func main() {[m[48;2;241;239;239m [m
+[38;2;255;56;139;48;2;255;235;238m- [m[38;2;32;31;38;48;2;255;235;238m fmt.Println("Hello, world!")[m
+[38;2;10;220;217;48;2;232;245;233m+ [m[38;2;32;31;38;48;2;232;245;233m content := "Hello, world!"[m[48;2;232;245;233m [m
+[38;2;10;220;217;48;2;232;245;233m+ [m[38;2;32;31;38;48;2;232;245;233m fmt.Println(content)[m[48;2;232;245;233m [m
+[38;2;32;31;38;48;2;241;239;239m }[m[48;2;241;239;239m [m