diff --git a/go.mod b/go.mod index 19f3d10..c204d38 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( gioui.org/x v0.9.0 // indirect git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 // indirect github.com/godbus/dbus/v5 v5.0.6 // indirect + github.com/sergi/go-diff v1.4.0 // indirect github.com/zodimo/go-lazy v0.1.1 // indirect golang.org/x/mod v0.32.0 // indirect golang.org/x/sys v0.41.0 // indirect diff --git a/go.sum b/go.sum index 096dd92..ca5cb3e 100644 --- a/go.sum +++ b/go.sum @@ -11,12 +11,22 @@ git.sr.ht/~schnwalter/gio-mw v0.0.0-20250713180710-9d8d98474447 h1:HYmUhTNys/xHf git.sr.ht/~schnwalter/gio-mw v0.0.0-20250713180710-9d8d98474447/go.mod h1:2delIHRFXOUBnmXbltTSUCnnbpzWawIGwQGxqw2K7p0= git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 h1:bGG/g4ypjrCJoSvFrP5hafr9PPB5aw8SjcOWWila7ZI= git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0/go.mod h1:+axXBRUTIDlCeE73IKeD/os7LoEnTKdkp8/gQOFjqyo= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-text/typesetting-utils v0.0.0-20250618110550-c820a94c77b8 h1:4KCscI9qYWMGTuz6BpJtbUSRzcBrUSSE0ENMJbNSrFs= github.com/go-text/typesetting-utils v0.0.0-20250618110550-c820a94c77b8/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o= github.com/godbus/dbus/v5 v5.0.6 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro= github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/zodimo/go-lazy v0.1.1 h1:lYsYK6eH1rbQ6VkVqPJntjiGIIoFe0Vsu4wH50j3uWE= github.com/zodimo/go-lazy v0.1.1/go.mod h1:+GKplxvAWyHv/XirM6KZwdHDvXBQDGGjCRpbUGlrZyg= github.com/zodimo/go-maybe v0.1.6 h1:Htq0qrJn/1OcMsKkoMSnjRBwEXyqWNLVuHAY/abSzu4= @@ -43,3 +53,7 @@ golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/pkg/x/diff/diff.go b/pkg/x/diff/diff.go new file mode 100644 index 0000000..a97704b --- /dev/null +++ b/pkg/x/diff/diff.go @@ -0,0 +1,151 @@ +package diff + +import ( + "strings" + + "github.com/sergi/go-diff/diffmatchpatch" + + "github.com/zodimo/go-compose/compose/foundation/layout/column" + "github.com/zodimo/go-compose/compose/foundation/layout/row" + fText "github.com/zodimo/go-compose/compose/foundation/text" + iconbutton "github.com/zodimo/go-compose/compose/material3/iconbutton" + m3Text "github.com/zodimo/go-compose/compose/material3/text" + "github.com/zodimo/go-compose/compose/ui/graphics" + "github.com/zodimo/go-compose/modifiers/background" + "github.com/zodimo/go-compose/modifiers/weight" + "github.com/zodimo/go-compose/pkg/api" + "github.com/zodimo/go-compose/compose/ui" + "golang.org/x/exp/shiny/materialdesign/icons" +) + +type ChangeType int + +const ( + ChangeEqual ChangeType = iota + ChangeDiff +) + +type DiffSection struct { + Type ChangeType + Original string + Modified string +} + +func CalculateDiff(original, modified string) []DiffSection { + dmp := diffmatchpatch.New() + a, b, c := dmp.DiffLinesToChars(original, modified) + diffs := dmp.DiffMain(a, b, false) + diffs = dmp.DiffCharsToLines(diffs, c) + + var sections []DiffSection + for i := 0; i < len(diffs); { + if diffs[i].Type == diffmatchpatch.DiffEqual { + sections = append(sections, DiffSection{ + Type: ChangeEqual, + Original: diffs[i].Text, + Modified: diffs[i].Text, + }) + i++ + } else { + // Combine adjacent deletes and inserts into a single section + var orig, mod strings.Builder + for i < len(diffs) && diffs[i].Type != diffmatchpatch.DiffEqual { + if diffs[i].Type == diffmatchpatch.DiffDelete { + orig.WriteString(diffs[i].Text) + } else if diffs[i].Type == diffmatchpatch.DiffInsert { + mod.WriteString(diffs[i].Text) + } + i++ + } + sections = append(sections, DiffSection{ + Type: ChangeDiff, + Original: orig.String(), + Modified: mod.String(), + }) + } + } + return sections +} + +// DiffViewer renders the diff between original and modified strings +func DiffViewer(original, modified string, onApplyLeftToRight, onApplyRightToLeft func(sectionIndex int, section DiffSection)) api.Composable { + sections := CalculateDiff(original, modified) + + return func(c api.Composer) api.Composer { + c.StartBlock("DiffViewer") + defer c.EndBlock() + + column.Column( + c.Sequence( + c.Range(len(sections), func(i int) api.Composable { + return row.Row( + c.Sequence( + // Left Side + row.Row( + c.Sequence(renderSection(i, sections[i], true, onApplyLeftToRight)), + row.WithModifier(weight.Weight(1)), + ), + // Right Side + row.Row( + c.Sequence(renderSection(i, sections[i], false, onApplyRightToLeft)), + row.WithModifier(weight.Weight(1)), + ), + ), + ) + }), + ), + )(c) + + return c + } +} + +func renderSection(index int, section DiffSection, isLeft bool, onApply func(int, DiffSection)) api.Composable { + return func(c api.Composer) api.Composer { + c.StartBlock("DiffSection") + defer c.EndBlock() + + return column.Column( + c.Sequence(func(c api.Composer) api.Composer { + textStr := section.Original + if !isLeft { + textStr = section.Modified + } + + if textStr == "" && section.Type == ChangeDiff { + // Placeholder for deleted/empty blocks + m3Text.Text("---", fText.WithModifier( + background.Background(graphics.Color(0xFFC8C8C8)), + ))(c) + return c + } + + lines := strings.Split(strings.TrimSuffix(textStr, "\n"), "\n") + var mod ui.Modifier = ui.EmptyModifier + + if section.Type == ChangeDiff { + if isLeft { + mod = background.Background(graphics.Color(0xFFFFC8C8)) // Faint red + } else { + mod = background.Background(graphics.Color(0xFFC8FFC8)) // Faint green + } + } + + c.Range(len(lines), func(j int) api.Composable { + return m3Text.Text(lines[j], fText.WithModifier(mod)) + })(c) + + // Inline button for changes + if section.Type == ChangeDiff { + if isLeft { + iconbutton.Standard(func() { onApply(index, section) }, icons.HardwareKeyboardArrowRight, "Apply Left to Right")(c) + } else { + iconbutton.Standard(func() { onApply(index, section) }, icons.HardwareKeyboardArrowLeft, "Apply Right to Left")(c) + } + } + + return c + }), + )(c) + } +} diff --git a/pkg/x/diff/diff_test.go b/pkg/x/diff/diff_test.go new file mode 100644 index 0000000..a620169 --- /dev/null +++ b/pkg/x/diff/diff_test.go @@ -0,0 +1,60 @@ +package diff + +import ( + "reflect" + "testing" +) + +func TestCalculateDiff(t *testing.T) { + tests := []struct { + name string + original string + modified string + want []DiffSection + }{ + { + name: "identical strings", + original: "line1\nline2\n", + modified: "line1\nline2\n", + want: []DiffSection{ + {Type: ChangeEqual, Original: "line1\nline2\n", Modified: "line1\nline2\n"}, + }, + }, + { + name: "one line changed", + original: "line1\nline2\nline3\n", + modified: "line1\nline2 modified\nline3\n", + want: []DiffSection{ + {Type: ChangeEqual, Original: "line1\n", Modified: "line1\n"}, + {Type: ChangeDiff, Original: "line2\n", Modified: "line2 modified\n"}, + {Type: ChangeEqual, Original: "line3\n", Modified: "line3\n"}, + }, + }, + { + name: "line added", + original: "line1\n", + modified: "line1\nline2\n", + want: []DiffSection{ + {Type: ChangeEqual, Original: "line1\n", Modified: "line1\n"}, + {Type: ChangeDiff, Original: "", Modified: "line2\n"}, + }, + }, + { + name: "line removed", + original: "line1\nline2\n", + modified: "line1\n", + want: []DiffSection{ + {Type: ChangeEqual, Original: "line1\n", Modified: "line1\n"}, + {Type: ChangeDiff, Original: "line2\n", Modified: ""}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := CalculateDiff(tt.original, tt.modified); !reflect.DeepEqual(got, tt.want) { + t.Errorf("CalculateDiff() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/run_tests.sh b/run_tests.sh new file mode 100755 index 0000000..d1e8778 --- /dev/null +++ b/run_tests.sh @@ -0,0 +1,2 @@ +#!/bin/bash +go test ./...