feat(diffview): show line numbers on the left

Andrey Nering created

Change summary

internal/exp/diffview/diffview.go                                 | 109 
internal/exp/diffview/diffview_test.go                            |  17 
internal/exp/diffview/style.go                                    |  96 
internal/exp/diffview/testdata/TestDefault/DarkMode.golden        |  14 
internal/exp/diffview/testdata/TestDefault/LightMode.golden       |  14 
internal/exp/diffview/testdata/TestNoLineNumbers/DarkMode.golden  |   7 
internal/exp/diffview/testdata/TestNoLineNumbers/LightMode.golden |   7 
internal/exp/diffview/util.go                                     |  14 
8 files changed, 197 insertions(+), 81 deletions(-)

Detailed changes

internal/exp/diffview/diffview.go 🔗

@@ -2,13 +2,13 @@ package diffview
 
 import (
 	"os"
+	"strconv"
 	"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
@@ -25,71 +25,13 @@ const (
 	layoutSplit
 )
 
-type LineStyle struct {
-	Symbol lipgloss.Style
-	Code   lipgloss.Style
-}
-
-type Style struct {
-	EqualLine  LineStyle
-	InsertLine LineStyle
-	DeleteLine LineStyle
-}
-
-var DefaultLightStyle = Style{
-	EqualLine: LineStyle{
-		Code: lipgloss.NewStyle().
-			Foreground(charmtone.Pepper).
-			Background(charmtone.Salt),
-	},
-	InsertLine: LineStyle{
-		Symbol: lipgloss.NewStyle().
-			Foreground(charmtone.Turtle).
-			Background(lipgloss.Color("#e8f5e9")),
-		Code: lipgloss.NewStyle().
-			Foreground(charmtone.Pepper).
-			Background(lipgloss.Color("#e8f5e9")),
-	},
-	DeleteLine: LineStyle{
-		Symbol: lipgloss.NewStyle().
-			Foreground(charmtone.Cherry).
-			Background(lipgloss.Color("#ffebee")),
-		Code: lipgloss.NewStyle().
-			Foreground(charmtone.Pepper).
-			Background(lipgloss.Color("#ffebee")),
-	},
-}
-
-var DefaultDarkStyle = Style{
-	EqualLine: LineStyle{
-		Code: lipgloss.NewStyle().
-			Foreground(charmtone.Salt).
-			Background(charmtone.Pepper),
-	},
-	InsertLine: LineStyle{
-		Symbol: lipgloss.NewStyle().
-			Foreground(charmtone.Turtle).
-			Background(lipgloss.Color("#303a30")),
-		Code: lipgloss.NewStyle().
-			Foreground(charmtone.Salt).
-			Background(lipgloss.Color("#303a30")),
-	},
-	DeleteLine: LineStyle{
-		Symbol: lipgloss.NewStyle().
-			Foreground(charmtone.Cherry).
-			Background(lipgloss.Color("#3a3030")),
-		Code: lipgloss.NewStyle().
-			Foreground(charmtone.Salt).
-			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
+	lineNumbers  bool
 	highlight    bool
 	height       int
 	width        int
@@ -106,6 +48,7 @@ func New() *DiffView {
 	dv := &DiffView{
 		layout:       layoutUnified,
 		contextLines: udiff.DefaultContextLines,
+		lineNumbers:  true,
 	}
 	if lipgloss.HasDarkBackground(os.Stdin, os.Stdout) {
 		dv.style = DefaultDarkStyle
@@ -151,8 +94,14 @@ func (dv *DiffView) Style(style Style) *DiffView {
 	return dv
 }
 
-// SyntaxHighlight sets whether to enable syntax highlighting in the DiffView.
-func (dv *DiffView) SyntaxHighlight(highlight bool) *DiffView {
+// LineNumbers sets whether to display line numbers in the DiffView.
+func (dv *DiffView) LineNumbers(lineNumbers bool) *DiffView {
+	dv.lineNumbers = lineNumbers
+	return dv
+}
+
+// SyntaxHightlight sets whether to enable syntax highlighting in the DiffView.
+func (dv *DiffView) SyntaxHightlight(highlight bool) *DiffView {
 	dv.highlight = highlight
 	return dv
 }
@@ -176,22 +125,48 @@ func (dv *DiffView) String() string {
 	}
 	dv.detectWidth()
 
+	lineNumberWidth := func(start, num int) int {
+		return len(strconv.Itoa(start + num))
+	}
+
 	var b strings.Builder
 
 	for _, h := range dv.unified.Hunks {
+		beforeLine := h.FromLine
+		afterLine := h.ToLine
+
+		beforeNumDigits := lineNumberWidth(h.FromLine, len(h.Lines))
+		afterNumDigits := lineNumberWidth(h.ToLine, len(h.Lines))
+
 		for _, l := range h.Lines {
 			content := strings.TrimSuffix(l.Content, "\n")
-			width := dv.width - leadingSymbolsSize
+			codeWidth := dv.width - leadingSymbolsSize
 
 			switch l.Kind {
+			case udiff.Equal:
+				if dv.lineNumbers {
+					b.WriteString(dv.style.EqualLine.LineNumber.Render(pad(beforeLine, beforeNumDigits)))
+					b.WriteString(dv.style.EqualLine.LineNumber.Render(pad(afterLine, afterNumDigits)))
+				}
+				b.WriteString(dv.style.EqualLine.Code.Width(codeWidth + leadingSymbolsSize).Render("  " + content))
+				beforeLine++
+				afterLine++
 			case udiff.Insert:
+				if dv.lineNumbers {
+					b.WriteString(dv.style.InsertLine.LineNumber.Render(pad(" ", beforeNumDigits)))
+					b.WriteString(dv.style.InsertLine.LineNumber.Render(pad(afterLine, afterNumDigits)))
+				}
 				b.WriteString(dv.style.InsertLine.Symbol.Render("+ "))
-				b.WriteString(dv.style.InsertLine.Code.Width(width).Render(content))
+				b.WriteString(dv.style.InsertLine.Code.Width(codeWidth).Render(content))
+				afterLine++
 			case udiff.Delete:
+				if dv.lineNumbers {
+					b.WriteString(dv.style.DeleteLine.LineNumber.Render(pad(beforeLine, beforeNumDigits)))
+					b.WriteString(dv.style.DeleteLine.LineNumber.Render(pad(" ", afterNumDigits)))
+				}
 				b.WriteString(dv.style.DeleteLine.Symbol.Render("- "))
-				b.WriteString(dv.style.DeleteLine.Code.Width(width).Render(content))
-			case udiff.Equal:
-				b.WriteString(dv.style.EqualLine.Code.Width(width + leadingSymbolsSize).Render("  " + content))
+				b.WriteString(dv.style.DeleteLine.Code.Width(codeWidth).Render(content))
+				beforeLine++
 			}
 			b.WriteRune('\n')
 		}

internal/exp/diffview/diffview_test.go 🔗

@@ -29,3 +29,20 @@ func TestDefault(t *testing.T) {
 		golden.RequireEqual(t, []byte(dv.String()))
 	})
 }
+
+func TestNoLineNumbers(t *testing.T) {
+	dv := diffview.New().
+		Before("main.go", TestDefaultBefore).
+		After("main.go", TestDefaultAfter).
+		LineNumbers(false)
+
+	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/style.go 🔗

@@ -0,0 +1,96 @@
+package diffview
+
+import (
+	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/x/exp/charmtone"
+)
+
+type LineStyle struct {
+	LineNumber lipgloss.Style
+	Symbol     lipgloss.Style
+	Code       lipgloss.Style
+}
+
+type Style struct {
+	EqualLine  LineStyle
+	InsertLine LineStyle
+	DeleteLine LineStyle
+}
+
+var DefaultLightStyle = Style{
+	EqualLine: LineStyle{
+		LineNumber: lipgloss.NewStyle().
+			Foreground(charmtone.Charcoal).
+			Background(charmtone.Ash).
+			Align(lipgloss.Right).
+			Padding(0, 1),
+		Code: lipgloss.NewStyle().
+			Foreground(charmtone.Pepper).
+			Background(charmtone.Salt),
+	},
+	InsertLine: LineStyle{
+		LineNumber: lipgloss.NewStyle().
+			Foreground(charmtone.Turtle).
+			Background(lipgloss.Color("#c8e6c9")).
+			Align(lipgloss.Right).
+			Padding(0, 1),
+		Symbol: lipgloss.NewStyle().
+			Foreground(charmtone.Turtle).
+			Background(lipgloss.Color("#e8f5e9")),
+		Code: lipgloss.NewStyle().
+			Foreground(charmtone.Pepper).
+			Background(lipgloss.Color("#e8f5e9")),
+	},
+	DeleteLine: LineStyle{
+		LineNumber: lipgloss.NewStyle().
+			Foreground(charmtone.Cherry).
+			Background(lipgloss.Color("#ffcdd2")).
+			Align(lipgloss.Left).
+			Padding(0, 1),
+		Symbol: lipgloss.NewStyle().
+			Foreground(charmtone.Cherry).
+			Background(lipgloss.Color("#ffebee")),
+		Code: lipgloss.NewStyle().
+			Foreground(charmtone.Pepper).
+			Background(lipgloss.Color("#ffebee")),
+	},
+}
+
+var DefaultDarkStyle = Style{
+	EqualLine: LineStyle{
+		LineNumber: lipgloss.NewStyle().
+			Foreground(charmtone.Ash).
+			Background(charmtone.Charcoal).
+			Align(lipgloss.Right).
+			Padding(0, 1),
+		Code: lipgloss.NewStyle().
+			Foreground(charmtone.Salt).
+			Background(charmtone.Pepper),
+	},
+	InsertLine: LineStyle{
+		LineNumber: lipgloss.NewStyle().
+			Foreground(charmtone.Turtle).
+			Background(lipgloss.Color("#293229")).
+			Align(lipgloss.Right).
+			Padding(0, 1),
+		Symbol: lipgloss.NewStyle().
+			Foreground(charmtone.Turtle).
+			Background(lipgloss.Color("#303a30")),
+		Code: lipgloss.NewStyle().
+			Foreground(charmtone.Salt).
+			Background(lipgloss.Color("#303a30")),
+	},
+	DeleteLine: LineStyle{
+		LineNumber: lipgloss.NewStyle().
+			Foreground(charmtone.Cherry).
+			Background(lipgloss.Color("#332929")).
+			Align(lipgloss.Left).
+			Padding(0, 1),
+		Symbol: lipgloss.NewStyle().
+			Foreground(charmtone.Cherry).
+			Background(lipgloss.Color("#3a3030")),
+		Code: lipgloss.NewStyle().
+			Foreground(charmtone.Salt).
+			Background(lipgloss.Color("#3a3030")),
+	},
+}

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

@@ -1,7 +1,7 @@
-  )                               
-                                  
-  func main() {                   
--     fmt.Println("Hello, world!")
-+     content := "Hello, world!"  
-+     fmt.Println(content)        
-  }                               
+  5   5   )                               
+  6   6                                   
+  7   7   func main() {                   
+  8     -     fmt.Println("Hello, world!")
+      8 +     content := "Hello, world!"  
+      9 +     fmt.Println(content)        
+  9  10   }                               

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

@@ -1,7 +1,7 @@
-  )                               
-                                  
-  func main() {                   
--     fmt.Println("Hello, world!")
-+     content := "Hello, world!"  
-+     fmt.Println(content)        
-  }                               
+  5   5   )                               
+  6   6                                   
+  7   7   func main() {                   
+  8     -     fmt.Println("Hello, world!")
+      8 +     content := "Hello, world!"  
+      9 +     fmt.Println(content)        
+  9  10   }                               

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

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

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

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

internal/exp/diffview/util.go 🔗

@@ -0,0 +1,14 @@
+package diffview
+
+import (
+	"fmt"
+	"strings"
+)
+
+func pad(v any, width int) string {
+	s := fmt.Sprintf("%v", v)
+	if len(s) >= width {
+		return s
+	}
+	return strings.Repeat(" ", width-len(s)) + s
+}