diff --git a/internal/exp/diffview/diffview.go b/internal/exp/diffview/diffview.go index c179f811cd3953d17884369fa4299bb9776fc630..7a7a0b90538d58b0ac4d60ade8486edd04c00179 100644 --- a/internal/exp/diffview/diffview.go +++ b/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) + } + } +} diff --git a/internal/exp/diffview/diffview_test.go b/internal/exp/diffview/diffview_test.go index 7d5abeab92444c32822642decb87108310fb826a..d82488087ec79bc288f7cbe879142b453b534116 100644 --- a/internal/exp/diffview/diffview_test.go +++ b/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, diff --git a/internal/exp/diffview/split.go b/internal/exp/diffview/split.go new file mode 100644 index 0000000000000000000000000000000000000000..9099afb425ac9bfa2236fbc464d5370d56580f25 --- /dev/null +++ b/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 +} diff --git a/internal/exp/diffview/style.go b/internal/exp/diffview/style.go index e8f21c75139c7fba2489feb2bd63c1aca9d2c7fa..b8c99e58e861b1acbc109d1b81cb2d6da2e39f9e 100644 --- a/internal/exp/diffview/style.go +++ b/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). diff --git a/internal/exp/diffview/testdata/TestDiffView/Split/CustomContextLines/DarkMode.golden b/internal/exp/diffview/testdata/TestDiffView/Split/CustomContextLines/DarkMode.golden new file mode 100644 index 0000000000000000000000000000000000000000..f304f5e74493245a7f840ce0535e3af661a192aa --- /dev/null +++ b/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  }  diff --git a/internal/exp/diffview/testdata/TestDiffView/Split/CustomContextLines/LightMode.golden b/internal/exp/diffview/testdata/TestDiffView/Split/CustomContextLines/LightMode.golden new file mode 100644 index 0000000000000000000000000000000000000000..6891f469fbbd89fa9e4251c894a4047ddf5d1487 --- /dev/null +++ b/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  }  diff --git a/internal/exp/diffview/testdata/TestDiffView/Split/Default/DarkMode.golden b/internal/exp/diffview/testdata/TestDiffView/Split/Default/DarkMode.golden new file mode 100644 index 0000000000000000000000000000000000000000..86acb63306bf03d4a49d540e2f9fb40cf63930fb --- /dev/null +++ b/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  }  diff --git a/internal/exp/diffview/testdata/TestDiffView/Split/Default/LightMode.golden b/internal/exp/diffview/testdata/TestDiffView/Split/Default/LightMode.golden new file mode 100644 index 0000000000000000000000000000000000000000..c198639301f381e8cec717a83208953ceba99b44 --- /dev/null +++ b/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  }  diff --git a/internal/exp/diffview/testdata/TestDiffView/Split/MultipleHunks/DarkMode.golden b/internal/exp/diffview/testdata/TestDiffView/Split/MultipleHunks/DarkMode.golden new file mode 100644 index 0000000000000000000000000000000000000000..1c248902db22d0391e84c1a61b4168c3cc0b7576 --- /dev/null +++ b/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  }  diff --git a/internal/exp/diffview/testdata/TestDiffView/Split/MultipleHunks/LightMode.golden b/internal/exp/diffview/testdata/TestDiffView/Split/MultipleHunks/LightMode.golden new file mode 100644 index 0000000000000000000000000000000000000000..32d746df6a20ca6b1ec14c2e64624b4e41aa845b --- /dev/null +++ b/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  }  diff --git a/internal/exp/diffview/testdata/TestDiffView/Split/Narrow/DarkMode.golden b/internal/exp/diffview/testdata/TestDiffView/Split/Narrow/DarkMode.golden new file mode 100644 index 0000000000000000000000000000000000000000..0ed25532003a24bf7bf393f8139fdb776359ad8f --- /dev/null +++ b/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  diff --git a/internal/exp/diffview/testdata/TestDiffView/Split/Narrow/LightMode.golden b/internal/exp/diffview/testdata/TestDiffView/Split/Narrow/LightMode.golden new file mode 100644 index 0000000000000000000000000000000000000000..287db708282839969d39a5fea0d87d2c27b74851 --- /dev/null +++ b/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  diff --git a/internal/exp/diffview/testdata/TestDiffView/Split/NoLineNumbers/DarkMode.golden b/internal/exp/diffview/testdata/TestDiffView/Split/NoLineNumbers/DarkMode.golden new file mode 100644 index 0000000000000000000000000000000000000000..270a701b16895e3210f1b52a6666ab81a50dd09b --- /dev/null +++ b/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)  + }  }  diff --git a/internal/exp/diffview/testdata/TestDiffView/Split/NoLineNumbers/LightMode.golden b/internal/exp/diffview/testdata/TestDiffView/Split/NoLineNumbers/LightMode.golden new file mode 100644 index 0000000000000000000000000000000000000000..f637b85c434a03924001d94c753c684ffeb2926e --- /dev/null +++ b/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)  + }  }