on main help sign in
feat(diffview): implement split / side-by-side view
commit0823b31fd08a7f098cc09cf916167b988c3e1a78 authorAndrey Nering <andreynering@users.noreply.github.com> dateFri 6 Jun 2025, 17:49 (1 year ago) parent38b91cc
14 files changed+325−2
internal/exp/diffview/diffview.go+134−2 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
internal/exp/diffview/diffview.go+134−2
@@ -42,6 +42,10 @@ type DiffView struct {
4242 err error
4343 unified udiff.UnifiedDiff
4444 edits []udiff.Edit
45+
46+ splitHunks []splitHunk
47+ beforeCodeWidth int
48+ afterCodeWidth int
4549}
4650
4751// New creates a new DiffView with default settings.
@@ -124,7 +128,20 @@ func (dv *DiffView) String() string {
124128 if err := dv.computeDiff(); err != nil {
125129 return err.Error()
126130 }
127− dv.detectWidth()
131+
132+ switch dv.layout {
133+ case layoutUnified:
134+ return dv.renderUnified()
135+ case layoutSplit:
136+ return dv.renderSplit()
137+ default:
138+ panic("unknown diffview layout")
139+ }
140+}
141+
142+// renderUnified renders the unified diff view as a string.
143+func (dv *DiffView) renderUnified() string {
144+ dv.detectUnifiedWidth()
128145
129146 codeWidth := dv.width - leadingSymbolsSize
130147 beforeNumDigits, afterNumDigits := dv.lineNumberDigits()
@@ -178,6 +195,88 @@ func (dv *DiffView) String() string {
178195 return b.String()
179196}
180197
198+// renderSplit renders the split (side-by-side) diff view as a string.
199+func (dv *DiffView) renderSplit() string {
200+ dv.convertDiffToSplit()
201+ dv.detectSplitWidth()
202+
203+ beforeNumDigits, afterNumDigits := dv.lineNumberDigits()
204+
205+ var b strings.Builder
206+
207+ for i, h := range dv.splitHunks {
208+ if dv.lineNumbers {
209+ b.WriteString(dv.style.DividerLine.LineNumber.Render(pad("…", beforeNumDigits)))
210+ }
211+ b.WriteString(dv.style.DividerLine.Code.Width(dv.beforeCodeWidth + leadingSymbolsSize).Render(dv.hunkLineFor(dv.unified.Hunks[i])))
212+ if dv.lineNumbers {
213+ b.WriteString(dv.style.DividerLine.LineNumber.Render(pad("…", afterNumDigits)))
214+ }
215+ b.WriteString(dv.style.DividerLine.Code.Width(dv.afterCodeWidth + leadingSymbolsSize).Render(" "))
216+ b.WriteRune('\n')
217+
218+ beforeLine := h.fromLine
219+ afterLine := h.toLine
220+
221+ for _, l := range h.lines {
222+ var beforeContent string
223+ var afterContent string
224+ if l.before != nil {
225+ beforeContent = strings.TrimSuffix(l.before.Content, "\n")
226+ }
227+ if l.after != nil {
228+ afterContent = strings.TrimSuffix(l.after.Content, "\n")
229+ }
230+
231+ switch {
232+ case l.before == nil:
233+ if dv.lineNumbers {
234+ b.WriteString(dv.style.MissingLine.LineNumber.Render(pad(" ", beforeNumDigits)))
235+ }
236+ b.WriteString(dv.style.MissingLine.Code.Width(dv.beforeCodeWidth + leadingSymbolsSize).Render(" "))
237+ case l.before.Kind == udiff.Equal:
238+ if dv.lineNumbers {
239+ b.WriteString(dv.style.EqualLine.LineNumber.Render(pad(beforeLine, beforeNumDigits)))
240+ }
241+ b.WriteString(dv.style.EqualLine.Code.Width(dv.beforeCodeWidth + leadingSymbolsSize).Render(" " + beforeContent))
242+ beforeLine++
243+ case l.before.Kind == udiff.Delete:
244+ if dv.lineNumbers {
245+ b.WriteString(dv.style.DeleteLine.LineNumber.Render(pad(beforeLine, beforeNumDigits)))
246+ }
247+ b.WriteString(dv.style.DeleteLine.Symbol.Render("- "))
248+ b.WriteString(dv.style.DeleteLine.Code.Width(dv.beforeCodeWidth).Render(beforeContent))
249+ beforeLine++
250+ }
251+
252+ switch {
253+ case l.after == nil:
254+ if dv.lineNumbers {
255+ b.WriteString(dv.style.MissingLine.LineNumber.Render(pad(" ", afterNumDigits)))
256+ }
257+ b.WriteString(dv.style.MissingLine.Code.Width(dv.afterCodeWidth + leadingSymbolsSize).Render(" "))
258+ case l.after.Kind == udiff.Equal:
259+ if dv.lineNumbers {
260+ b.WriteString(dv.style.EqualLine.LineNumber.Render(pad(afterLine, afterNumDigits)))
261+ }
262+ b.WriteString(dv.style.EqualLine.Code.Width(dv.afterCodeWidth + leadingSymbolsSize).Render(" " + afterContent))
263+ afterLine++
264+ case l.after.Kind == udiff.Insert:
265+ if dv.lineNumbers {
266+ b.WriteString(dv.style.InsertLine.LineNumber.Render(pad(afterLine, afterNumDigits)))
267+ }
268+ b.WriteString(dv.style.InsertLine.Symbol.Render("+ "))
269+ b.WriteString(dv.style.InsertLine.Code.Width(dv.afterCodeWidth).Render(afterContent))
270+ afterLine++
271+ }
272+
273+ b.WriteRune('\n')
274+ }
275+ }
276+
277+ return b.String()
278+}
279+
181280func (dv *DiffView) computeDiff() error {
182281 if dv.isComputed {
183282 return dv.err
@@ -236,7 +335,7 @@ func (dv *DiffView) hunkShownLines(h *udiff.Hunk) (before, after int) {
236335 return
237336}
238337
239−func (dv *DiffView) detectWidth() {
338+func (dv *DiffView) detectUnifiedWidth() {
240339 if dv.width > 0 {
241340 return
242341 }
@@ -251,3 +350,36 @@ func (dv *DiffView) detectWidth() {
251350 }
252351 }
253352}
353+
354+func (dv *DiffView) convertDiffToSplit() {
355+ dv.splitHunks = make([]splitHunk, len(dv.unified.Hunks))
356+ for i, h := range dv.unified.Hunks {
357+ dv.splitHunks[i] = hunkToSplit(h)
358+ }
359+}
360+
361+func (dv *DiffView) detectSplitWidth() {
362+ if dv.width > 0 {
363+ return
364+ }
365+
366+ for i, h := range dv.splitHunks {
367+ shownLines := ansi.StringWidth(dv.hunkLineFor(dv.unified.Hunks[i]))
368+
369+ for _, l := range h.lines {
370+ var lineWidth int
371+ if l.before != nil {
372+ codeWidth := ansi.StringWidth(strings.TrimSuffix(l.before.Content, "\n")) + 1
373+ dv.beforeCodeWidth = max(dv.beforeCodeWidth, codeWidth, shownLines)
374+ lineWidth += codeWidth
375+ }
376+ if l.after != nil {
377+ codeWidth := ansi.StringWidth(strings.TrimSuffix(l.after.Content, "\n")) + 1
378+ dv.afterCodeWidth = max(dv.afterCodeWidth, codeWidth, shownLines)
379+ lineWidth += codeWidth
380+ }
381+ lineWidth += leadingSymbolsSize * 2
382+ dv.width = max(dv.width, lineWidth, shownLines)
383+ }
384+ }
385+}
internal/exp/diffview/diffview_test.go+4
@@ -35,6 +35,9 @@ var (
3535 UnifiedFunc = func(dv *diffview.DiffView) *diffview.DiffView {
3636 return dv.Unified()
3737 }
38+ SplitFunc = func(dv *diffview.DiffView) *diffview.DiffView {
39+ return dv.Split()
40+ }
3841
3942 DefaultFunc = func(dv *diffview.DiffView) *diffview.DiffView {
4043 return dv.
@@ -73,6 +76,7 @@ var (
7376
7477 LayoutFuncs = TestFuncs{
7578 "Unified": UnifiedFunc,
79+ "Split": SplitFunc,
7680 }
7781 BehaviorFuncs = TestFuncs{
7882 "Default": DefaultFunc,
internal/exp/diffview/split.gonew file+74
@@ -0,0 +1,74 @@
1+package diffview
2+
3+import (
4+ "slices"
5+
6+ "github.com/aymanbagabas/go-udiff"
7+ "github.com/charmbracelet/x/exp/slice"
8+)
9+
10+type splitHunk struct {
11+ fromLine int
12+ toLine int
13+ lines []*splitLine
14+}
15+
16+type splitLine struct {
17+ before *udiff.Line
18+ after *udiff.Line
19+}
20+
21+func hunkToSplit(h *udiff.Hunk) (sh splitHunk) {
22+ lines := slices.Clone(h.Lines)
23+ sh = splitHunk{
24+ fromLine: h.FromLine,
25+ toLine: h.ToLine,
26+ lines: make([]*splitLine, 0, len(lines)),
27+ }
28+
29+ for {
30+ var ul udiff.Line
31+ var ok bool
32+ ul, lines, ok = slice.Shift(lines)
33+ if !ok {
34+ break
35+ }
36+
37+ var sl splitLine
38+
39+ switch ul.Kind {
40+
41+ // For equal lines, add as is
42+ case udiff.Equal:
43+ sl.before = &ul
44+ sl.after = &ul
45+
46+ // For inserted lines, set after and keep before as nil
47+ case udiff.Insert:
48+ sl.before = nil
49+ sl.after = &ul
50+
51+ // For deleted lines, set before and loop over the next lines
52+ // searching for the equivalent after line.
53+ case udiff.Delete:
54+ sl.before = &ul
55+
56+ inner:
57+ for i, l := range lines {
58+ switch l.Kind {
59+ case udiff.Insert:
60+ var ll udiff.Line
61+ ll, lines, _ = slice.DeleteAt(lines, i)
62+ sl.after = &ll
63+ break inner
64+ case udiff.Equal:
65+ break inner
66+ }
67+ }
68+ }
69+
70+ sh.lines = append(sh.lines, &sl)
71+ }
72+
73+ return
74+}
internal/exp/diffview/style.go+15
@@ -13,6 +13,7 @@ type LineStyle struct {
1313
1414type Style struct {
1515 DividerLine LineStyle
16+ MissingLine LineStyle
1617 EqualLine LineStyle
1718 InsertLine LineStyle
1819 DeleteLine LineStyle
@@ -29,6 +30,13 @@ var DefaultLightStyle = Style{
2930 Foreground(charmtone.Oyster).
3031 Background(charmtone.Anchovy),
3132 },
33+ MissingLine: LineStyle{
34+ LineNumber: lipgloss.NewStyle().
35+ Background(charmtone.Ash).
36+ Padding(0, 1),
37+ Code: lipgloss.NewStyle().
38+ Background(charmtone.Ash),
39+ },
3240 EqualLine: LineStyle{
3341 LineNumber: lipgloss.NewStyle().
3442 Foreground(charmtone.Charcoal).
@@ -78,6 +86,13 @@ var DefaultDarkStyle = Style{
7886 Foreground(charmtone.Smoke).
7987 Background(charmtone.Ox),
8088 },
89+ MissingLine: LineStyle{
90+ LineNumber: lipgloss.NewStyle().
91+ Background(charmtone.Charcoal).
92+ Padding(0, 1),
93+ Code: lipgloss.NewStyle().
94+ Background(charmtone.Charcoal),
95+ },
8196 EqualLine: LineStyle{
8297 LineNumber: lipgloss.NewStyle().
8398 Foreground(charmtone.Ash).
internal/exp/diffview/testdata/TestDiffView/Split/CustomContextLines/DarkMode.goldennew file+16
@@ -0,0 +1,16 @@
1+  …  @@ -1,13 +1,15 @@    …   
2+  1  package main   1  package main 
3+  2     2   
4+  3  import (   3  import ( 
5+  4  "fmt"   4  "fmt" 
6+       5 +  "strings" 
7+  5  )   6  ) 
8+  6     7   
9+  7  func main() {   8  func main() { 
10+  8  fmt.Println(getContent())   9  fmt.Println(getContent()) 
11+  9  }  10  } 
12+ 10    11   
13+ 11  func getContent() string {  12  func getContent() string { 
14+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
15+      14 +  return content 
16+ 13  }  15  } 
internal/exp/diffview/testdata/TestDiffView/Split/CustomContextLines/LightMode.goldennew file+16
@@ -0,0 +1,16 @@
1+  …  @@ -1,13 +1,15 @@    …   
2+  1  package main   1  package main 
3+  2     2   
4+  3  import (   3  import ( 
5+  4  "fmt"   4  "fmt" 
6+       5 +  "strings" 
7+  5  )   6  ) 
8+  6     7   
9+  7  func main() {   8  func main() { 
10+  8  fmt.Println(getContent())   9  fmt.Println(getContent()) 
11+  9  }  10  } 
12+ 10    11   
13+ 11  func getContent() string {  12  func getContent() string { 
14+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
15+      14 +  return content 
16+ 13  }  15  } 
internal/exp/diffview/testdata/TestDiffView/Split/Default/DarkMode.goldennew file+7
@@ -0,0 +1,7 @@
1+  …  @@ -5,5 +5,6 @@    …   
2+  5  )   5  ) 
3+  6     6   
4+  7  func main() {   7  func main() { 
5+  8 -  fmt.Println("Hello, world!")   8 +  content := "Hello, world!" 
6+       9 +  fmt.Println(content) 
7+  9  }  10  } 
internal/exp/diffview/testdata/TestDiffView/Split/Default/LightMode.goldennew file+7
@@ -0,0 +1,7 @@
1+  …  @@ -5,5 +5,6 @@    …   
2+  5  )   5  ) 
3+  6     6   
4+  7  func main() {   7  func main() { 
5+  8 -  fmt.Println("Hello, world!")   8 +  content := "Hello, world!" 
6+       9 +  fmt.Println(content) 
7+  9  }  10  } 
internal/exp/diffview/testdata/TestDiffView/Split/MultipleHunks/DarkMode.goldennew file+15
@@ -0,0 +1,15 @@
1+  …  @@ -2,6 +2,7 @@    …   
2+  2     2   
3+  3  import (   3  import ( 
4+  4  "fmt"   4  "fmt" 
5+       5 +  "strings" 
6+  5  )   6  ) 
7+  6     7   
8+  7  func main() {   8  func main() { 
9+  …  @@ -9,5 +10,6 @@    …   
10+  9  }  10  } 
11+ 10    11   
12+ 11  func getContent() string {  12  func getContent() string { 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
14+      14 +  return content 
15+ 13  }  15  } 
internal/exp/diffview/testdata/TestDiffView/Split/MultipleHunks/LightMode.goldennew file+15
@@ -0,0 +1,15 @@
1+  …  @@ -2,6 +2,7 @@    …   
2+  2     2   
3+  3  import (   3  import ( 
4+  4  "fmt"   4  "fmt" 
5+       5 +  "strings" 
6+  5  )   6  ) 
7+  6     7   
8+  7  func main() {   8  func main() { 
9+  …  @@ -9,5 +10,6 @@    …   
10+  9  }  10  } 
11+ 10    11   
12+ 11  func getContent() string {  12  func getContent() string { 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
14+      14 +  return content 
15+ 13  }  15  } 
internal/exp/diffview/testdata/TestDiffView/Split/Narrow/DarkMode.goldennew file+4
@@ -0,0 +1,4 @@
1+ …  @@ -1,3 +1,3 @@   …   
2+ 1 - a  1 + d 
3+ 2 - b  2 + e 
4+ 3 - c  3 + f 
internal/exp/diffview/testdata/TestDiffView/Split/Narrow/LightMode.goldennew file+4
@@ -0,0 +1,4 @@
1+ …  @@ -1,3 +1,3 @@   …   
2+ 1 - a  1 + d 
3+ 2 - b  2 + e 
4+ 3 - c  3 + f 
internal/exp/diffview/testdata/TestDiffView/Split/NoLineNumbers/DarkMode.goldennew file+7
@@ -0,0 +1,7 @@
1+ @@ -5,5 +5,6 @@    
2+ )  ) 
3+    
4+ func main() {  func main() { 
5+-  fmt.Println("Hello, world!") +  content := "Hello, world!" 
6+  +  fmt.Println(content) 
7+ }  } 
internal/exp/diffview/testdata/TestDiffView/Split/NoLineNumbers/LightMode.goldennew file+7
@@ -0,0 +1,7 @@
1+ @@ -5,5 +5,6 @@    
2+ )  ) 
3+    
4+ func main() {  func main() { 
5+-  fmt.Println("Hello, world!") +  content := "Hello, world!" 
6+  +  fmt.Println(content) 
7+ }  }