feat(diffview): implement split / side-by-side view

Andrey Nering created

Change summary

internal/exp/diffview/diffview.go                                                     | 136 
internal/exp/diffview/diffview_test.go                                                |   4 
internal/exp/diffview/split.go                                                        |  74 
internal/exp/diffview/style.go                                                        |  15 
internal/exp/diffview/testdata/TestDiffView/Split/CustomContextLines/DarkMode.golden  |  16 
internal/exp/diffview/testdata/TestDiffView/Split/CustomContextLines/LightMode.golden |  16 
internal/exp/diffview/testdata/TestDiffView/Split/Default/DarkMode.golden             |   7 
internal/exp/diffview/testdata/TestDiffView/Split/Default/LightMode.golden            |   7 
internal/exp/diffview/testdata/TestDiffView/Split/MultipleHunks/DarkMode.golden       |  15 
internal/exp/diffview/testdata/TestDiffView/Split/MultipleHunks/LightMode.golden      |  15 
internal/exp/diffview/testdata/TestDiffView/Split/Narrow/DarkMode.golden              |   4 
internal/exp/diffview/testdata/TestDiffView/Split/Narrow/LightMode.golden             |   4 
internal/exp/diffview/testdata/TestDiffView/Split/NoLineNumbers/DarkMode.golden       |   7 
internal/exp/diffview/testdata/TestDiffView/Split/NoLineNumbers/LightMode.golden      |   7 
14 files changed, 325 insertions(+), 2 deletions(-)

Detailed changes

internal/exp/diffview/diffview.go šŸ”—

@@ -42,6 +42,10 @@ type DiffView struct {
 	err        error
 	unified    udiff.UnifiedDiff
 	edits      []udiff.Edit
+
+	splitHunks      []splitHunk
+	beforeCodeWidth int
+	afterCodeWidth  int
 }
 
 // New creates a new DiffView with default settings.
@@ -124,7 +128,20 @@ func (dv *DiffView) String() string {
 	if err := dv.computeDiff(); err != nil {
 		return err.Error()
 	}
-	dv.detectWidth()
+
+	switch dv.layout {
+	case layoutUnified:
+		return dv.renderUnified()
+	case layoutSplit:
+		return dv.renderSplit()
+	default:
+		panic("unknown diffview layout")
+	}
+}
+
+// renderUnified renders the unified diff view as a string.
+func (dv *DiffView) renderUnified() string {
+	dv.detectUnifiedWidth()
 
 	codeWidth := dv.width - leadingSymbolsSize
 	beforeNumDigits, afterNumDigits := dv.lineNumberDigits()
@@ -178,6 +195,88 @@ func (dv *DiffView) String() string {
 	return b.String()
 }
 
+// renderSplit renders the split (side-by-side) diff view as a string.
+func (dv *DiffView) renderSplit() string {
+	dv.convertDiffToSplit()
+	dv.detectSplitWidth()
+
+	beforeNumDigits, afterNumDigits := dv.lineNumberDigits()
+
+	var b strings.Builder
+
+	for i, h := range dv.splitHunks {
+		if dv.lineNumbers {
+			b.WriteString(dv.style.DividerLine.LineNumber.Render(pad("…", beforeNumDigits)))
+		}
+		b.WriteString(dv.style.DividerLine.Code.Width(dv.beforeCodeWidth + leadingSymbolsSize).Render(dv.hunkLineFor(dv.unified.Hunks[i])))
+		if dv.lineNumbers {
+			b.WriteString(dv.style.DividerLine.LineNumber.Render(pad("…", afterNumDigits)))
+		}
+		b.WriteString(dv.style.DividerLine.Code.Width(dv.afterCodeWidth + leadingSymbolsSize).Render(" "))
+		b.WriteRune('\n')
+
+		beforeLine := h.fromLine
+		afterLine := h.toLine
+
+		for _, l := range h.lines {
+			var beforeContent string
+			var afterContent string
+			if l.before != nil {
+				beforeContent = strings.TrimSuffix(l.before.Content, "\n")
+			}
+			if l.after != nil {
+				afterContent = strings.TrimSuffix(l.after.Content, "\n")
+			}
+
+			switch {
+			case l.before == nil:
+				if dv.lineNumbers {
+					b.WriteString(dv.style.MissingLine.LineNumber.Render(pad(" ", beforeNumDigits)))
+				}
+				b.WriteString(dv.style.MissingLine.Code.Width(dv.beforeCodeWidth + leadingSymbolsSize).Render("  "))
+			case l.before.Kind == udiff.Equal:
+				if dv.lineNumbers {
+					b.WriteString(dv.style.EqualLine.LineNumber.Render(pad(beforeLine, beforeNumDigits)))
+				}
+				b.WriteString(dv.style.EqualLine.Code.Width(dv.beforeCodeWidth + leadingSymbolsSize).Render("  " + beforeContent))
+				beforeLine++
+			case l.before.Kind == udiff.Delete:
+				if dv.lineNumbers {
+					b.WriteString(dv.style.DeleteLine.LineNumber.Render(pad(beforeLine, beforeNumDigits)))
+				}
+				b.WriteString(dv.style.DeleteLine.Symbol.Render("- "))
+				b.WriteString(dv.style.DeleteLine.Code.Width(dv.beforeCodeWidth).Render(beforeContent))
+				beforeLine++
+			}
+
+			switch {
+			case l.after == nil:
+				if dv.lineNumbers {
+					b.WriteString(dv.style.MissingLine.LineNumber.Render(pad(" ", afterNumDigits)))
+				}
+				b.WriteString(dv.style.MissingLine.Code.Width(dv.afterCodeWidth + leadingSymbolsSize).Render("  "))
+			case l.after.Kind == udiff.Equal:
+				if dv.lineNumbers {
+					b.WriteString(dv.style.EqualLine.LineNumber.Render(pad(afterLine, afterNumDigits)))
+				}
+				b.WriteString(dv.style.EqualLine.Code.Width(dv.afterCodeWidth + leadingSymbolsSize).Render("  " + afterContent))
+				afterLine++
+			case l.after.Kind == udiff.Insert:
+				if dv.lineNumbers {
+					b.WriteString(dv.style.InsertLine.LineNumber.Render(pad(afterLine, afterNumDigits)))
+				}
+				b.WriteString(dv.style.InsertLine.Symbol.Render("+ "))
+				b.WriteString(dv.style.InsertLine.Code.Width(dv.afterCodeWidth).Render(afterContent))
+				afterLine++
+			}
+
+			b.WriteRune('\n')
+		}
+	}
+
+	return b.String()
+}
+
 func (dv *DiffView) computeDiff() error {
 	if dv.isComputed {
 		return dv.err
@@ -236,7 +335,7 @@ func (dv *DiffView) hunkShownLines(h *udiff.Hunk) (before, after int) {
 	return
 }
 
-func (dv *DiffView) detectWidth() {
+func (dv *DiffView) detectUnifiedWidth() {
 	if dv.width > 0 {
 		return
 	}
@@ -251,3 +350,36 @@ func (dv *DiffView) detectWidth() {
 		}
 	}
 }
+
+func (dv *DiffView) convertDiffToSplit() {
+	dv.splitHunks = make([]splitHunk, len(dv.unified.Hunks))
+	for i, h := range dv.unified.Hunks {
+		dv.splitHunks[i] = hunkToSplit(h)
+	}
+}
+
+func (dv *DiffView) detectSplitWidth() {
+	if dv.width > 0 {
+		return
+	}
+
+	for i, h := range dv.splitHunks {
+		shownLines := ansi.StringWidth(dv.hunkLineFor(dv.unified.Hunks[i]))
+
+		for _, l := range h.lines {
+			var lineWidth int
+			if l.before != nil {
+				codeWidth := ansi.StringWidth(strings.TrimSuffix(l.before.Content, "\n")) + 1
+				dv.beforeCodeWidth = max(dv.beforeCodeWidth, codeWidth, shownLines)
+				lineWidth += codeWidth
+			}
+			if l.after != nil {
+				codeWidth := ansi.StringWidth(strings.TrimSuffix(l.after.Content, "\n")) + 1
+				dv.afterCodeWidth = max(dv.afterCodeWidth, codeWidth, shownLines)
+				lineWidth += codeWidth
+			}
+			lineWidth += leadingSymbolsSize * 2
+			dv.width = max(dv.width, lineWidth, shownLines)
+		}
+	}
+}

internal/exp/diffview/diffview_test.go šŸ”—

@@ -35,6 +35,9 @@ var (
 	UnifiedFunc = func(dv *diffview.DiffView) *diffview.DiffView {
 		return dv.Unified()
 	}
+	SplitFunc = func(dv *diffview.DiffView) *diffview.DiffView {
+		return dv.Split()
+	}
 
 	DefaultFunc = func(dv *diffview.DiffView) *diffview.DiffView {
 		return dv.
@@ -73,6 +76,7 @@ var (
 
 	LayoutFuncs = TestFuncs{
 		"Unified": UnifiedFunc,
+		"Split":   SplitFunc,
 	}
 	BehaviorFuncs = TestFuncs{
 		"Default":            DefaultFunc,

internal/exp/diffview/split.go šŸ”—

@@ -0,0 +1,74 @@
+package diffview
+
+import (
+	"slices"
+
+	"github.com/aymanbagabas/go-udiff"
+	"github.com/charmbracelet/x/exp/slice"
+)
+
+type splitHunk struct {
+	fromLine int
+	toLine   int
+	lines    []*splitLine
+}
+
+type splitLine struct {
+	before *udiff.Line
+	after  *udiff.Line
+}
+
+func hunkToSplit(h *udiff.Hunk) (sh splitHunk) {
+	lines := slices.Clone(h.Lines)
+	sh = splitHunk{
+		fromLine: h.FromLine,
+		toLine:   h.ToLine,
+		lines:    make([]*splitLine, 0, len(lines)),
+	}
+
+	for {
+		var ul udiff.Line
+		var ok bool
+		ul, lines, ok = slice.Shift(lines)
+		if !ok {
+			break
+		}
+
+		var sl splitLine
+
+		switch ul.Kind {
+
+		// For equal lines, add as is
+		case udiff.Equal:
+			sl.before = &ul
+			sl.after = &ul
+
+		// For inserted lines, set after and keep before as nil
+		case udiff.Insert:
+			sl.before = nil
+			sl.after = &ul
+
+		// For deleted lines, set before and loop over the next lines
+		// searching for the equivalent after line.
+		case udiff.Delete:
+			sl.before = &ul
+
+		inner:
+			for i, l := range lines {
+				switch l.Kind {
+				case udiff.Insert:
+					var ll udiff.Line
+					ll, lines, _ = slice.DeleteAt(lines, i)
+					sl.after = &ll
+					break inner
+				case udiff.Equal:
+					break inner
+				}
+			}
+		}
+
+		sh.lines = append(sh.lines, &sl)
+	}
+
+	return
+}

internal/exp/diffview/style.go šŸ”—

@@ -13,6 +13,7 @@ type LineStyle struct {
 
 type Style struct {
 	DividerLine LineStyle
+	MissingLine LineStyle
 	EqualLine   LineStyle
 	InsertLine  LineStyle
 	DeleteLine  LineStyle
@@ -29,6 +30,13 @@ var DefaultLightStyle = Style{
 			Foreground(charmtone.Oyster).
 			Background(charmtone.Anchovy),
 	},
+	MissingLine: LineStyle{
+		LineNumber: lipgloss.NewStyle().
+			Background(charmtone.Ash).
+			Padding(0, 1),
+		Code: lipgloss.NewStyle().
+			Background(charmtone.Ash),
+	},
 	EqualLine: LineStyle{
 		LineNumber: lipgloss.NewStyle().
 			Foreground(charmtone.Charcoal).
@@ -78,6 +86,13 @@ var DefaultDarkStyle = Style{
 			Foreground(charmtone.Smoke).
 			Background(charmtone.Ox),
 	},
+	MissingLine: LineStyle{
+		LineNumber: lipgloss.NewStyle().
+			Background(charmtone.Charcoal).
+			Padding(0, 1),
+		Code: lipgloss.NewStyle().
+			Background(charmtone.Charcoal),
+	},
 	EqualLine: LineStyle{
 		LineNumber: lipgloss.NewStyle().
 			Foreground(charmtone.Ash).

internal/exp/diffview/testdata/TestDiffView/Split/CustomContextLines/DarkMode.golden šŸ”—

@@ -0,0 +1,16 @@
+Ā  …Ā   @@ -1,13 +1,15 @@             Ā  …Ā                                                   
+Ā  1Ā   package main                  Ā  1Ā   package main                                    
+Ā  2Ā                                 Ā  2Ā                                                   
+Ā  3Ā   import (                      Ā  3Ā   import (                                        
+Ā  4Ā       "fmt"                     Ā  4Ā       "fmt"                                       
+Ā   Ā                                 Ā  5Ā +     "strings"                                   
+Ā  5Ā   )                             Ā  6Ā   )                                               
+Ā  6Ā                                 Ā  7Ā                                                   
+Ā  7Ā   func main() {                 Ā  8Ā   func main() {                                   
+Ā  8Ā       fmt.Println(getContent()) Ā  9Ā       fmt.Println(getContent())                   
+Ā  9Ā   }                             Ā 10Ā   }                                               
+Ā 10Ā                                 Ā 11Ā                                                   
+Ā 11Ā   func getContent() string {    Ā 12Ā   func getContent() string {                      
+Ā 12Ā -     return "Hello, world!"    Ā 13Ā +     content := strings.ToUpper("Hello, World!") 
+Ā   Ā                                 Ā 14Ā +     return content                              
+Ā 13Ā   }                             Ā 15Ā   }                                               

internal/exp/diffview/testdata/TestDiffView/Split/CustomContextLines/LightMode.golden šŸ”—

@@ -0,0 +1,16 @@
+Ā  …Ā   @@ -1,13 +1,15 @@             Ā  …Ā                                                   
+Ā  1Ā   package main                  Ā  1Ā   package main                                    
+Ā  2Ā                                 Ā  2Ā                                                   
+Ā  3Ā   import (                      Ā  3Ā   import (                                        
+Ā  4Ā       "fmt"                     Ā  4Ā       "fmt"                                       
+Ā   Ā                                 Ā  5Ā +     "strings"                                   
+Ā  5Ā   )                             Ā  6Ā   )                                               
+Ā  6Ā                                 Ā  7Ā                                                   
+Ā  7Ā   func main() {                 Ā  8Ā   func main() {                                   
+Ā  8Ā       fmt.Println(getContent()) Ā  9Ā       fmt.Println(getContent())                   
+Ā  9Ā   }                             Ā 10Ā   }                                               
+Ā 10Ā                                 Ā 11Ā                                                   
+Ā 11Ā   func getContent() string {    Ā 12Ā   func getContent() string {                      
+Ā 12Ā -     return "Hello, world!"    Ā 13Ā +     content := strings.ToUpper("Hello, World!") 
+Ā   Ā                                 Ā 14Ā +     return content                              
+Ā 13Ā   }                             Ā 15Ā   }                                               

internal/exp/diffview/testdata/TestDiffView/Split/Default/DarkMode.golden šŸ”—

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

internal/exp/diffview/testdata/TestDiffView/Split/Default/LightMode.golden šŸ”—

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

internal/exp/diffview/testdata/TestDiffView/Split/MultipleHunks/DarkMode.golden šŸ”—

@@ -0,0 +1,15 @@
+Ā  …Ā   @@ -2,6 +2,7 @@            Ā  …Ā                                                   
+Ā  2Ā                              Ā  2Ā                                                   
+Ā  3Ā   import (                   Ā  3Ā   import (                                        
+Ā  4Ā       "fmt"                  Ā  4Ā       "fmt"                                       
+Ā   Ā                              Ā  5Ā +     "strings"                                   
+Ā  5Ā   )                          Ā  6Ā   )                                               
+Ā  6Ā                              Ā  7Ā                                                   
+Ā  7Ā   func main() {              Ā  8Ā   func main() {                                   
+Ā  …Ā   @@ -9,5 +10,6 @@           Ā  …Ā                                                   
+Ā  9Ā   }                          Ā 10Ā   }                                               
+Ā 10Ā                              Ā 11Ā                                                   
+Ā 11Ā   func getContent() string { Ā 12Ā   func getContent() string {                      
+Ā 12Ā -     return "Hello, world!" Ā 13Ā +     content := strings.ToUpper("Hello, World!") 
+Ā   Ā                              Ā 14Ā +     return content                              
+Ā 13Ā   }                          Ā 15Ā   }                                               

internal/exp/diffview/testdata/TestDiffView/Split/MultipleHunks/LightMode.golden šŸ”—

@@ -0,0 +1,15 @@
+Ā  …Ā   @@ -2,6 +2,7 @@            Ā  …Ā                                                   
+Ā  2Ā                              Ā  2Ā                                                   
+Ā  3Ā   import (                   Ā  3Ā   import (                                        
+Ā  4Ā       "fmt"                  Ā  4Ā       "fmt"                                       
+Ā   Ā                              Ā  5Ā +     "strings"                                   
+Ā  5Ā   )                          Ā  6Ā   )                                               
+Ā  6Ā                              Ā  7Ā                                                   
+Ā  7Ā   func main() {              Ā  8Ā   func main() {                                   
+Ā  …Ā   @@ -9,5 +10,6 @@           Ā  …Ā                                                   
+Ā  9Ā   }                          Ā 10Ā   }                                               
+Ā 10Ā                              Ā 11Ā                                                   
+Ā 11Ā   func getContent() string { Ā 12Ā   func getContent() string {                      
+Ā 12Ā -     return "Hello, world!" Ā 13Ā +     content := strings.ToUpper("Hello, World!") 
+Ā   Ā                              Ā 14Ā +     return content                              
+Ā 13Ā   }                          Ā 15Ā   }                                               

internal/exp/diffview/testdata/TestDiffView/Split/Narrow/DarkMode.golden šŸ”—

@@ -0,0 +1,4 @@
+Ā …Ā   @@ -1,3 +1,3 @@   Ā …Ā                     
+Ā 1Ā - a                 Ā 1Ā + d                 
+Ā 2Ā - b                 Ā 2Ā + e                 
+Ā 3Ā - c                 Ā 3Ā + f                 

internal/exp/diffview/testdata/TestDiffView/Split/Narrow/LightMode.golden šŸ”—

@@ -0,0 +1,4 @@
+Ā …Ā   @@ -1,3 +1,3 @@   Ā …Ā                     
+Ā 1Ā - a                 Ā 1Ā + d                 
+Ā 2Ā - b                 Ā 2Ā + e                 
+Ā 3Ā - c                 Ā 3Ā + f                 

internal/exp/diffview/testdata/TestDiffView/Split/NoLineNumbers/DarkMode.golden šŸ”—

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

internal/exp/diffview/testdata/TestDiffView/Split/NoLineNumbers/LightMode.golden šŸ”—

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