Split into category packages; module github.com/ZacharyZhang-NY/MujicaUI
This commit is contained in:
1822 files changed
+24568
-21314
No files matched your search
@@ -0,0 +1,68 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// AnsiTextOptions configure an AnsiText.
|
||||
type AnsiTextOptions struct {
|
||||
// Wrap breaks long lines at the width.
|
||||
Wrap bool
|
||||
}
|
||||
|
||||
// AnsiText renders terminal output with its SGR styles.
|
||||
func AnsiText(c *ui.Context, text string, opts AnsiTextOptions) *ui.Element {
|
||||
k := core.Tokens(c)
|
||||
col := ui.Column(c).Font(theme.MonoFont).TextColor(k.Text).Shrink(0)
|
||||
col.Children(func() {
|
||||
var st internal.AnsiStyle
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
var spans []internal.AnsiSpan
|
||||
spans, st = internal.ParseAnsi(line, st)
|
||||
out := make([]ui.Span, 0, len(spans))
|
||||
for _, s := range spans {
|
||||
out = append(out, ansiTextSpan(k, s))
|
||||
}
|
||||
if len(out) == 0 {
|
||||
out = append(out, ui.Span{Text: " "})
|
||||
}
|
||||
t := ui.RichText(c, out...)
|
||||
if !opts.Wrap {
|
||||
t.NoWrap()
|
||||
}
|
||||
}
|
||||
})
|
||||
return col
|
||||
}
|
||||
|
||||
// ansiTextSpan maps one parsed span onto theme-readable colors.
|
||||
func ansiTextSpan(k theme.Tokens, s internal.AnsiSpan) ui.Span {
|
||||
fg, hasFG := internal.AnsiResolve(k.Dark, s.Style.FG)
|
||||
if !hasFG {
|
||||
fg = k.Text
|
||||
}
|
||||
bg, hasBG := internal.AnsiResolve(k.Dark, s.Style.BG)
|
||||
if s.Style.Inverse {
|
||||
if !hasBG {
|
||||
bg = k.Surface
|
||||
}
|
||||
fg, bg, hasBG = bg, fg, true
|
||||
}
|
||||
if s.Style.Dim {
|
||||
fg = fg.Mix(k.Surface, 0.4)
|
||||
}
|
||||
out := ui.Span{Text: s.Text, Color: fg, Italic: s.Style.Italic, Underline: s.Style.Underline, Strikethrough: s.Style.Strike}
|
||||
if s.Style.Bold {
|
||||
out.Weight = 700
|
||||
}
|
||||
if hasBG {
|
||||
out.Background = bg
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
func TestAnsiTextStripsSGRKeepsUnknown(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
AnsiText(c, "\x1b[1;31merror\x1b[0m: bad\n\x1b[2Kcleared", AnsiTextOptions{})
|
||||
}, 400, 100)
|
||||
if !tt.HasText("error: bad") {
|
||||
t.Fatalf("styled line: %q", tt.Texts())
|
||||
}
|
||||
if !tt.HasText("\x1b[2Kcleared") {
|
||||
t.Fatalf("unknown escape not kept: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnsiTextSpanStyles(t *testing.T) {
|
||||
k := theme.Light()
|
||||
parse := func(s string) ui.Span {
|
||||
spans, _ := internal.ParseAnsi(s, internal.AnsiStyle{})
|
||||
return ansiTextSpan(k, spans[0])
|
||||
}
|
||||
red, _ := internal.AnsiResolve(false, internal.AnsiColor{Kind: internal.AnsiIndexed, Index: 1})
|
||||
if s := parse("\x1b[1;3;4;31mx"); s.Color != red || s.Weight != 700 || !s.Italic || !s.Underline {
|
||||
t.Errorf("bold italic underline red: %+v", s)
|
||||
}
|
||||
if s := parse("\x1b[38;5;196mx"); s.Color != ui.RGB(255, 0, 0) {
|
||||
t.Errorf("256 color: %+v", s.Color)
|
||||
}
|
||||
if s := parse("\x1b[38;2;10;20;30;48;2;1;2;3mx"); s.Color != ui.RGB(10, 20, 30) || s.Background != ui.RGB(1, 2, 3) {
|
||||
t.Errorf("true color: %+v", s)
|
||||
}
|
||||
if s := parse("\x1b[7mx"); s.Color != k.Surface || s.Background != k.Text {
|
||||
t.Errorf("inverse: %+v", s)
|
||||
}
|
||||
if s := parse("\x1b[9mx"); !s.Strikethrough || s.Color != k.Text {
|
||||
t.Errorf("strike: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnsiTextSnapshot(t *testing.T) {
|
||||
snapshot(t, "ansi_text", 520, 200, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
AnsiText(c, "\x1b[1mBuilding\x1b[0m mujica \x1b[2m(v1.1)\x1b[0m\n"+
|
||||
"\x1b[32m✓\x1b[0m 300 passed \x1b[31m✗\x1b[0m 2 failed \x1b[33m!\x1b[0m 1 skipped\n"+
|
||||
"\x1b[30m0\x1b[31m1\x1b[32m2\x1b[33m3\x1b[34m4\x1b[35m5\x1b[36m6\x1b[37m7\x1b[90m8\x1b[91m9\x1b[92mA\x1b[93mB\x1b[94mC\x1b[95mD\x1b[96mE\x1b[97mF\x1b[0m\n"+
|
||||
"\x1b[38;5;208m256-color\x1b[0m \x1b[38;2;125;32;52mtrue color\x1b[0m \x1b[4munderline\x1b[0m \x1b[3mitalic\x1b[0m \x1b[7m inverse \x1b[0m\n"+
|
||||
"\x1b[41;97m FAIL \x1b[0m kept: \x1b[2K", AnsiTextOptions{})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// BlameLine is a source line and its last commit.
|
||||
type BlameLine struct {
|
||||
Hash string
|
||||
Author string
|
||||
When time.Time
|
||||
Summary string
|
||||
Text string
|
||||
}
|
||||
|
||||
// BlameViewState keeps scroll place and highlight state.
|
||||
type BlameViewState struct {
|
||||
list ui.ListState
|
||||
blocks []bool
|
||||
src *BlameLine
|
||||
n int
|
||||
lang string
|
||||
}
|
||||
|
||||
// BlameViewOptions configure a BlameView.
|
||||
type BlameViewOptions struct {
|
||||
// Language picks syntax colors, as "go"; empty is plain.
|
||||
Language string
|
||||
// TimeFormat lays out commit dates; empty means "2006-01-02".
|
||||
TimeFormat string
|
||||
}
|
||||
|
||||
// BlameView shows code with its commits; give it a height.
|
||||
func BlameView(c *ui.Context, s *BlameViewState, lines []BlameLine, opts BlameViewOptions) *ui.Element {
|
||||
var first *BlameLine
|
||||
if len(lines) > 0 {
|
||||
first = &lines[0]
|
||||
}
|
||||
if first != s.src || len(lines) != s.n || opts.Language != s.lang {
|
||||
s.blocks = blameViewBlocks(lines, opts.Language)
|
||||
s.src, s.n, s.lang = first, len(lines), opts.Language
|
||||
slog.Debug("mujica: blame view index", "lines", len(lines))
|
||||
}
|
||||
layout := opts.TimeFormat
|
||||
if layout == "" {
|
||||
layout = "2006-01-02"
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
u := d.Unit()
|
||||
digits := float32(len(strconv.Itoa(len(lines))))
|
||||
s.list.Label = func(i int) string { return lines[i].Text }
|
||||
e := ui.List(c, &s.list, len(lines), func(i int) {
|
||||
ln := lines[i]
|
||||
if ln.Hash == "" {
|
||||
panic("mujica: BlameLine needs a Hash")
|
||||
}
|
||||
head := i == 0 || lines[i-1].Hash != ln.Hash
|
||||
row := ui.Row(c).AlignItems(ui.Stretch).MinHeight(theme.MinHitSize)
|
||||
if head && i > 0 {
|
||||
row.Draw(func(p *ui.Painter, r ui.Rect) { p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W, H: 1}, k.Border, 0) })
|
||||
}
|
||||
row.Children(func() {
|
||||
gut := ui.Row(c).Width(240).Shrink(0).AlignItems(ui.Center).Gap(u*2).Padding(0, u*3).Background(k.Surface).
|
||||
Label(fmt.Sprintf(core.Msg(c, "blame_view.line"), i+1, ln.Author, gitShortHash(ln.Hash)))
|
||||
gut.Tooltip(fmt.Sprintf("%s\n%s · %s\n%s", ln.Hash, ln.Author, ln.When.Format(layout), ln.Summary))
|
||||
if gut.Hovered() {
|
||||
gut.Background(k.SurfaceHover)
|
||||
}
|
||||
gut.Transition(core.ColorFade)
|
||||
gut.Children(func() {
|
||||
if !head {
|
||||
return
|
||||
}
|
||||
small := core.FontSize(c, theme.CaptionSize)
|
||||
ui.Text(c, gitShortHash(ln.Hash)).Font(theme.MonoFont).FontSize(small).TextColor(k.AccentText).Shrink(0)
|
||||
ui.Text(c, ln.Author).FontSize(small).SingleLine().Grow(1).MinWidth(0)
|
||||
ui.Text(c, ln.When.Format(layout)).FontSize(small).FontFeatures("tnum").TextColor(k.TextMuted).Shrink(0)
|
||||
})
|
||||
ui.Text(c, strconv.Itoa(i+1)).Font(theme.MonoFont).FontSize(core.FontSize(c, theme.CaptionSize)).FontFeatures("tnum").
|
||||
TextColor(k.TextMuted).Width(digits*8+u*4).TextAlign(ui.End).Padding(u, u*2, 0, 0).Shrink(0)
|
||||
spans, _ := internal.CodeSpans(k, opts.Language, ln.Text, s.blocks[i])
|
||||
ui.RichText(c, spans...).Font(theme.MonoFont).NoWrap().Padding(u, u*2).AlignSelf(ui.Center)
|
||||
})
|
||||
})
|
||||
e.Background(k.Background).Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius)
|
||||
return e
|
||||
}
|
||||
|
||||
// blameViewBlocks records where block comments are open.
|
||||
func blameViewBlocks(lines []BlameLine, language string) []bool {
|
||||
blocks := make([]bool, len(lines))
|
||||
open := false
|
||||
for i, ln := range lines {
|
||||
blocks[i] = open
|
||||
_, open = internal.Highlight(language, ln.Text, open)
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func blameSample() []BlameLine {
|
||||
a := BlameLine{Hash: gitTestHash(1), Author: "Sakiko", When: gitTestTime, Summary: "Add the graph"}
|
||||
b := BlameLine{Hash: gitTestHash(2), Author: "Uika", When: gitTestTime, Summary: "Comment the lanes"}
|
||||
lines := []string{"package mujica", "", "/* lanes", " per row */", "func lanes() int {", "\treturn 2", "}"}
|
||||
out := make([]BlameLine, len(lines))
|
||||
for i, s := range lines {
|
||||
ln := a
|
||||
if i >= 2 && i <= 3 {
|
||||
ln = b
|
||||
}
|
||||
ln.Text = s
|
||||
out[i] = ln
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestBlameViewGroupsCommits(t *testing.T) {
|
||||
var s BlameViewState
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
BlameView(c, &s, blameSample(), BlameViewOptions{Language: "go"}).Height(300)
|
||||
}, 700, 340)
|
||||
count := 0
|
||||
for _, txt := range tt.Texts() {
|
||||
if txt == "Sakiko" || txt == "Uika" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
// Three runs: lines 1-2, 3-4 and 5-7.
|
||||
if count != 3 {
|
||||
t.Fatalf("author shown %d times, want 3: %q", count, tt.Texts())
|
||||
}
|
||||
if _, ok := tt.Find(fmt.Sprintf("Line 4, Uika, %s", gitShortHash(gitTestHash(2)))); !ok {
|
||||
t.Fatal("line 4 gutter not named")
|
||||
}
|
||||
if !s.blocks[3] || s.blocks[4] {
|
||||
t.Fatalf("block comment state %v", s.blocks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlameViewHoverDetails(t *testing.T) {
|
||||
var s BlameViewState
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
BlameView(c, &s, blameSample(), BlameViewOptions{}).Height(300)
|
||||
}, 700, 340)
|
||||
r, _ := tt.Find("Uika")
|
||||
tt.Move(r.X+2, r.Y+2)
|
||||
time.Sleep(650 * time.Millisecond)
|
||||
tt.Move(r.X+3, r.Y+2)
|
||||
tt.Frame()
|
||||
if tt.HasText(gitTestHash(2) + "\nUika · 2026-10-05\nComment the lanes") {
|
||||
return
|
||||
}
|
||||
t.Fatalf("no hover details: %q", tt.Texts())
|
||||
}
|
||||
|
||||
func TestBlameViewVirtualized(t *testing.T) {
|
||||
lines := make([]BlameLine, 100000)
|
||||
for i := range lines {
|
||||
lines[i] = BlameLine{Hash: gitTestHash(i / 3), Author: "Sakiko", When: gitTestTime, Text: fmt.Sprintf("line %d", i)}
|
||||
}
|
||||
var s BlameViewState
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
BlameView(c, &s, lines, BlameViewOptions{}).Height(400)
|
||||
}, 700, 440)
|
||||
if n := len(tt.Texts()); n > 400 {
|
||||
t.Fatalf("%d texts for 100,000 lines", n)
|
||||
}
|
||||
blocks := &s.blocks[0]
|
||||
tt.Frame()
|
||||
if &s.blocks[0] != blocks {
|
||||
t.Fatal("highlight state recomputed without a data change")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlameViewSnapshot(t *testing.T) {
|
||||
lines := blameSample()
|
||||
snapshot(t, "blame_view", 640, 300, func(c *ui.Context) {
|
||||
s := ui.Local(c.Root(), "blame", func() *BlameViewState { return &BlameViewState{} })
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
BlameView(c, *s, lines, BlameViewOptions{Language: "go"}).Height(260)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// Branch is a local or remote branch.
|
||||
type Branch struct {
|
||||
// Name is unique; remote names carry the remote, as origin/main.
|
||||
Name string
|
||||
Remote bool
|
||||
// Current marks the checked-out branch.
|
||||
Current bool
|
||||
// Upstream is the tracked remote branch, if any.
|
||||
Upstream string
|
||||
Ahead, Behind int
|
||||
}
|
||||
|
||||
// GitAction is a row action of a version-control list.
|
||||
type GitAction struct {
|
||||
ID string
|
||||
Label string
|
||||
// Icon names an icon from icons/.
|
||||
Icon string
|
||||
}
|
||||
|
||||
// BranchListOptions configure a BranchList.
|
||||
type BranchListOptions struct {
|
||||
// Actions show on every row; Action returns their IDs.
|
||||
Actions []GitAction
|
||||
Status data.DataStatus
|
||||
Error string
|
||||
}
|
||||
|
||||
// BranchList shows branches with upstream and ahead/behind counts.
|
||||
func BranchList(c *ui.Context, s *data.ListState[string], branches []Branch, opts BranchListOptions) GitListResult {
|
||||
gitActionsCheck(opts.Actions)
|
||||
var r GitListResult
|
||||
v := data.List(c, s, len(branches), data.ListOptions[string]{
|
||||
Key: func(i int) string { return branchKey(branches[i]) },
|
||||
Label: func(i int) string { return branches[i].Name },
|
||||
Status: opts.Status, Error: opts.Error, Empty: core.Msg(c, "branch_list.empty"),
|
||||
}, func(i int) {
|
||||
b := branches[i]
|
||||
branchListRow(c, b)
|
||||
gitRowActions(c, opts.Actions, b.Name, &r)
|
||||
})
|
||||
r.Element, r.changed, r.submitted = v.Element, v.Changed(), v.Submitted()
|
||||
return r
|
||||
}
|
||||
|
||||
func branchKey(b Branch) string {
|
||||
if b.Name == "" {
|
||||
panic("mujica: Branch needs a Name")
|
||||
}
|
||||
if b.Ahead < 0 || b.Behind < 0 {
|
||||
panic("mujica: Branch Ahead and Behind must not be negative")
|
||||
}
|
||||
return b.Name
|
||||
}
|
||||
|
||||
func branchListRow(c *ui.Context, b Branch) {
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
small := core.FontSize(c, theme.CaptionSize)
|
||||
mark := ui.Box(c).Size(16, 16).Shrink(0).Center().Role(ui.RoleNone)
|
||||
if b.Current {
|
||||
mark.TextColor(k.AccentText).Children(func() { ui.Icon(c, icons.Must("check")).Size(14, 14) })
|
||||
}
|
||||
ui.Icon(c, icons.Must("git-branch")).Size(14, 14).Shrink(0).TextColor(k.TextMuted)
|
||||
name := ui.Text(c, b.Name).SingleLine().MinWidth(0).Shrink(1)
|
||||
if b.Current {
|
||||
name.FontWeight(600)
|
||||
ui.Text(c, core.Msg(c, "branch_list.current")).FontSize(small).TextColor(k.AccentText).Shrink(0)
|
||||
}
|
||||
ui.Spacer(c)
|
||||
if b.Upstream != "" {
|
||||
ui.Row(c).Gap(u).AlignItems(ui.Center).Shrink(0).TextColor(k.TextMuted).Children(func() {
|
||||
ui.Text(c, b.Upstream).FontSize(small).SingleLine().MaxWidth(160)
|
||||
if b.Ahead > 0 {
|
||||
ui.Text(c, fmt.Sprintf("↑%d", b.Ahead)).FontSize(small).FontFeatures("tnum").Label(fmt.Sprintf(core.Msg(c, "branch_list.ahead"), b.Ahead))
|
||||
}
|
||||
if b.Behind > 0 {
|
||||
ui.Text(c, fmt.Sprintf("↓%d", b.Behind)).FontSize(small).FontFeatures("tnum").Label(fmt.Sprintf(core.Msg(c, "branch_list.behind"), b.Behind))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// gitActionsCheck panics on incomplete or duplicate actions.
|
||||
func gitActionsCheck(actions []GitAction) {
|
||||
seen := map[string]bool{}
|
||||
for _, a := range actions {
|
||||
if a.ID == "" || a.Label == "" || a.Icon == "" {
|
||||
panic("mujica: GitAction needs an ID, Label and Icon")
|
||||
}
|
||||
if seen[a.ID] {
|
||||
panic("mujica: duplicate GitAction " + a.ID)
|
||||
}
|
||||
seen[a.ID] = true
|
||||
icons.Must(a.Icon)
|
||||
}
|
||||
}
|
||||
|
||||
// gitRowActions shows row actions and records the one used.
|
||||
func gitRowActions(c *ui.Context, actions []GitAction, item string, r *GitListResult) {
|
||||
for _, a := range actions {
|
||||
b := core.IconAction(c, icons.Must(a.Icon), a.Label).Tooltip(a.Label).Shrink(0)
|
||||
if b.Clicked() && !b.IsDisabled() {
|
||||
r.action, r.item = a.ID, item
|
||||
slog.Debug("mujica: git list action", "action", a.ID, "item", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
)
|
||||
|
||||
func branchSample() []Branch {
|
||||
return []Branch{
|
||||
{Name: "main", Current: true, Upstream: "origin/main", Ahead: 2, Behind: 1},
|
||||
{Name: "feature/graph", Upstream: "origin/feature/graph"},
|
||||
{Name: "fix/blame"},
|
||||
{Name: "origin/main", Remote: true},
|
||||
{Name: "origin/release", Remote: true},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchListRowsAndActions(t *testing.T) {
|
||||
var s data.ListState[string]
|
||||
var acted [][2]string
|
||||
actions := []GitAction{{ID: "checkout", Label: "Check out", Icon: "git-branch"}, {ID: "delete", Label: "Delete", Icon: "trash-2"}}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := BranchList(c, &s, branchSample(), BranchListOptions{Actions: actions})
|
||||
r.Element.Height(300)
|
||||
if a, item, ok := r.Action(); ok {
|
||||
acted = append(acted, [2]string{a, item})
|
||||
}
|
||||
}, 700, 340)
|
||||
if !tt.HasText("current") || !tt.HasText("origin/feature/graph") {
|
||||
t.Fatalf("current, ahead, behind: %q", tt.Texts())
|
||||
}
|
||||
for _, name := range []string{"2 ahead of upstream", "1 behind upstream"} {
|
||||
if _, ok := tt.Find(name); !ok {
|
||||
t.Fatalf("%q not shown", name)
|
||||
}
|
||||
}
|
||||
r, _ := tt.Find("fix/blame")
|
||||
if err := tt.Click("fix/blame"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if key, _ := s.Selected(); key != "fix/blame" {
|
||||
t.Fatalf("selected %q", key)
|
||||
}
|
||||
// The third row's Delete button sits at the row's right end.
|
||||
tt.ClickAt(700-60, r.Y+r.H/2)
|
||||
if len(acted) != 1 || acted[0] != [2]string{"delete", "fix/blame"} {
|
||||
t.Fatalf("actions %q", acted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchListInvalid(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
b []Branch
|
||||
a []GitAction
|
||||
}{
|
||||
"name": {[]Branch{{}}, nil},
|
||||
"ahead": {[]Branch{{Name: "x", Ahead: -1}}, nil},
|
||||
"action": {nil, []GitAction{{ID: "x"}}},
|
||||
"dup": {nil, []GitAction{{ID: "x", Label: "X", Icon: "x"}, {ID: "x", Label: "Y", Icon: "x"}}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
var s data.ListState[string]
|
||||
BranchList(c, &s, tc.b, BranchListOptions{Actions: tc.a}).Element.Height(100)
|
||||
}, 300, 120, 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchListSnapshot(t *testing.T) {
|
||||
snapshot(t, "branch_list", 600, 260, func(c *ui.Context) {
|
||||
s := ui.Local(c.Root(), "list", func() *data.ListState[string] { return &data.ListState[string]{} })
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
BranchList(c, *s, branchSample(), BranchListOptions{Actions: []GitAction{{ID: "checkout", Label: "Check out", Icon: "git-branch"}}}).Element.Height(220)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/overlay"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// BranchSelectorOptions configure a BranchSelector.
|
||||
type BranchSelectorOptions struct {
|
||||
// AllowCreate offers to create a branch named by the search.
|
||||
AllowCreate bool
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// BranchSelectorResult is a built BranchSelector and its events.
|
||||
type BranchSelectorResult struct {
|
||||
Element *ui.Element
|
||||
changed bool
|
||||
create string
|
||||
}
|
||||
|
||||
// Changed reports a new branch in *selected this frame.
|
||||
func (r BranchSelectorResult) Changed() bool { return r.changed }
|
||||
|
||||
// Created returns a branch name to create this frame.
|
||||
func (r BranchSelectorResult) Created() (string, bool) { return r.create, r.create != "" }
|
||||
|
||||
type branchSelectorState struct {
|
||||
open, wasOpen bool
|
||||
query string
|
||||
hl int
|
||||
reveal bool
|
||||
// found caches the filter of src for query fq.
|
||||
found []Branch
|
||||
fq string
|
||||
src *Branch
|
||||
n int
|
||||
}
|
||||
|
||||
// BranchSelector chooses a branch from a searchable popup.
|
||||
func BranchSelector(c *ui.Context, selected *string, branches []Branch, opts BranchSelectorOptions) BranchSelectorResult {
|
||||
for _, b := range branches {
|
||||
branchKey(b)
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
u := d.Unit()
|
||||
tr := ui.ButtonBase(c).MinHeight(d.ControlHeight()).Padding(0, u*3).Gap(u*2).Radius(theme.ControlRadius).
|
||||
Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder).Disabled(opts.Disabled).
|
||||
Label(core.Msg(c, "branch_selector.label") + ": " + *selected)
|
||||
internal.Ring(tr, k.Focus, theme.ControlRadius)
|
||||
if tr.Hovered() {
|
||||
tr.Background(k.SurfaceHover)
|
||||
}
|
||||
tr.Transition(core.ColorFade)
|
||||
st := ui.Local(tr, "state", func() *branchSelectorState { return &branchSelectorState{} })
|
||||
s := *st
|
||||
res := BranchSelectorResult{Element: tr}
|
||||
if tr.Clicked() && !tr.IsDisabled() {
|
||||
s.open = !s.open
|
||||
}
|
||||
if tr.IsDisabled() {
|
||||
s.open = false
|
||||
}
|
||||
if s.open && !s.wasOpen {
|
||||
s.query, s.hl = "", 0
|
||||
slog.Debug("mujica: branch selector open")
|
||||
}
|
||||
s.wasOpen = s.open
|
||||
tr.Children(func() {
|
||||
ui.Icon(c, icons.Must("git-branch")).Size(16, 16).TextColor(k.TextMuted)
|
||||
ui.Text(c, *selected).SingleLine().MaxWidth(220)
|
||||
ui.Icon(c, icons.Must("chevrons-up-down")).Size(14, 14).TextColor(k.TextMuted)
|
||||
})
|
||||
b := tr.Bounds()
|
||||
ui.PopoverBase(c, tr, &s.open, func(panel *ui.Element) {
|
||||
core.OverlayStyle(c, panel, overlay.OverlayAnchored(c, panel, b))
|
||||
panel.Margin(6, 0, 0, 0).Padding(u * 2).Gap(u * 2).Width(300).MaxHeight(overlay.PopoverRoom(c, b)).AlignItems(ui.Stretch)
|
||||
var first *Branch
|
||||
if len(branches) > 0 {
|
||||
first = &branches[0]
|
||||
}
|
||||
if s.found == nil || s.fq != s.query || s.src != first || s.n != len(branches) {
|
||||
s.found, s.fq, s.src, s.n = branchSelectorFilter(branches, s.query), s.query, first, len(branches)
|
||||
}
|
||||
found := s.found
|
||||
create := opts.AllowCreate && strings.TrimSpace(s.query) != "" && !branchSelectorHas(branches, strings.TrimSpace(s.query))
|
||||
n := len(found)
|
||||
if create {
|
||||
n++
|
||||
}
|
||||
in := input.TextInput(c, &s.query, input.TextInputOptions{Placeholder: core.Msg(c, "branch_selector.search"), Label: core.Msg(c, "branch_selector.search"),
|
||||
Leading: func() { ui.Icon(c, icons.Must("search")).Size(14, 14).TextColor(k.TextMuted) }})
|
||||
in.Input.AutoFocus()
|
||||
if in.Changed() {
|
||||
s.hl, s.reveal = 0, true
|
||||
}
|
||||
switch {
|
||||
case n == 0:
|
||||
case in.Input.Shortcut(0, ui.KeyDown):
|
||||
s.hl, s.reveal = (s.hl+1)%n, true
|
||||
case in.Input.Shortcut(0, ui.KeyUp):
|
||||
s.hl, s.reveal = (s.hl-1+n)%n, true
|
||||
}
|
||||
s.hl = max(0, min(s.hl, n-1))
|
||||
pick := -1
|
||||
if in.Submitted() && n > 0 {
|
||||
pick = s.hl
|
||||
}
|
||||
ui.Scroll(c).Shrink(1).Gap(2).Role(ui.RoleList).Children(func() {
|
||||
if n == 0 {
|
||||
ui.Text(c, core.Msg(c, "branch_selector.empty")).TextColor(k.TextMuted).Padding(u * 3)
|
||||
}
|
||||
group := -1
|
||||
for i, br := range found {
|
||||
if g := branchSelectorGroup(br); g != group {
|
||||
group = g
|
||||
ui.Text(c, core.Msg(c, []string{"branch_selector.local", "branch_selector.remote"}[g])).Font(theme.SerifFont).
|
||||
FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.Ornament.Mix(k.Text, 0.35)).Padding(u*2, u*2, u, u*2)
|
||||
}
|
||||
if branchSelectorRow(c, s, i, br.Name, br.Name == *selected, "git-branch") {
|
||||
pick = i
|
||||
}
|
||||
}
|
||||
if create && branchSelectorRow(c, s, len(found), core.Msg(c, "branch_selector.create")+" “"+strings.TrimSpace(s.query)+"”", false, "plus") {
|
||||
pick = len(found)
|
||||
}
|
||||
})
|
||||
switch {
|
||||
case pick < 0:
|
||||
case pick == len(found):
|
||||
res.create = strings.TrimSpace(s.query)
|
||||
s.open = false
|
||||
slog.Debug("mujica: branch selector create", "name", res.create)
|
||||
default:
|
||||
s.open = false
|
||||
if found[pick].Name != *selected {
|
||||
*selected, res.changed = found[pick].Name, true
|
||||
slog.Debug("mujica: branch selector choose", "branch", *selected)
|
||||
}
|
||||
}
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
// branchSelectorRow builds row i and reports a click on it.
|
||||
func branchSelectorRow(c *ui.Context, s *branchSelectorState, i int, label string, current bool, icon string) bool {
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
u := d.Unit()
|
||||
row := ui.Row(c).AlignItems(ui.Center).Gap(u*2).MinHeight(d.ControlHeight()).Padding(0, u*2).Radius(theme.ControlRadius).
|
||||
Role(ui.RoleListItem).Label(label).Cursor(ui.CursorPointer)
|
||||
if i == s.hl {
|
||||
row.Background(k.Selection)
|
||||
if s.reveal {
|
||||
row.ScrollIntoView()
|
||||
s.reveal = false
|
||||
}
|
||||
} else if row.Hovered() {
|
||||
row.Background(k.SurfaceHover)
|
||||
}
|
||||
row.Transition(core.ColorFade)
|
||||
row.Children(func() {
|
||||
ui.Icon(c, icons.Must(icon)).Size(14, 14).TextColor(k.TextMuted)
|
||||
t := ui.Text(c, label).SingleLine().Grow(1).MinWidth(0)
|
||||
if current {
|
||||
t.FontWeight(600)
|
||||
ui.Icon(c, icons.Must("check")).Size(14, 14).TextColor(k.AccentText)
|
||||
}
|
||||
})
|
||||
return row.Clicked()
|
||||
}
|
||||
|
||||
func branchSelectorGroup(b Branch) int {
|
||||
if b.Remote {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// branchSelectorFilter keeps branches whose name holds the query, locals first.
|
||||
func branchSelectorFilter(branches []Branch, query string) []Branch {
|
||||
q := strings.ToLower(strings.TrimSpace(query))
|
||||
var local, remote []Branch
|
||||
for _, b := range branches {
|
||||
if !strings.Contains(strings.ToLower(b.Name), q) {
|
||||
continue
|
||||
}
|
||||
if b.Remote {
|
||||
remote = append(remote, b)
|
||||
} else {
|
||||
local = append(local, b)
|
||||
}
|
||||
}
|
||||
return append(append(make([]Branch, 0, len(local)+len(remote)), local...), remote...)
|
||||
}
|
||||
|
||||
func branchSelectorHas(branches []Branch, name string) bool {
|
||||
for _, b := range branches {
|
||||
if b.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestBranchSelectorSearchAndChoose(t *testing.T) {
|
||||
sel, changes := "main", 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if BranchSelector(c, &sel, branchSample(), BranchSelectorOptions{}).Changed() {
|
||||
changes++
|
||||
}
|
||||
}, 600, 500)
|
||||
if err := tt.Click("Branch: main"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tt.HasText("Local") || !tt.HasText("Remote") || !tt.HasText("origin/release") {
|
||||
t.Fatalf("groups: %q", tt.Texts())
|
||||
}
|
||||
tt.Type("rel")
|
||||
if tt.HasText("fix/blame") || !tt.HasText("origin/release") {
|
||||
t.Fatalf("filter: %q", tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if sel != "origin/release" || changes != 1 || tt.HasText("Remote") {
|
||||
t.Fatalf("Enter chose %q changes %d", sel, changes)
|
||||
}
|
||||
_ = tt.Click("Branch: origin/release")
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if sel != "fix/blame" {
|
||||
t.Fatalf("arrows chose %q", sel)
|
||||
}
|
||||
_ = tt.Click("Branch: fix/blame")
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if tt.HasText("Local") {
|
||||
t.Fatal("Escape did not close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchSelectorCreateAndDisabled(t *testing.T) {
|
||||
sel, created, disabled := "main", "", false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := BranchSelector(c, &sel, branchSample(), BranchSelectorOptions{AllowCreate: true, Disabled: disabled})
|
||||
if name, ok := r.Created(); ok {
|
||||
created = name
|
||||
}
|
||||
}, 600, 500)
|
||||
_ = tt.Click("Branch: main")
|
||||
tt.Type("feature/new")
|
||||
if !tt.HasText("Create branch “feature/new”") {
|
||||
t.Fatalf("create row: %q", tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if created != "feature/new" || sel != "main" {
|
||||
t.Fatalf("created %q sel %q", created, sel)
|
||||
}
|
||||
_ = tt.Click("Branch: main")
|
||||
disabled = true
|
||||
tt.Frame()
|
||||
if tt.HasText("Local") {
|
||||
t.Fatal("popup stayed open on a disabled selector")
|
||||
}
|
||||
_ = tt.Click("Branch: main")
|
||||
if tt.HasText("Local") {
|
||||
t.Fatal("disabled selector opened")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchSelectorSnapshot(t *testing.T) {
|
||||
snapshotAfter(t, "branch_selector", 420, 420, func(c *ui.Context) {
|
||||
sel := ui.Local(c.Root(), "sel", func() string { return "main" })
|
||||
ui.Column(c).Padding(16).AlignItems(ui.Start).Children(func() {
|
||||
BranchSelector(c, sel, branchSample(), BranchSelectorOptions{AllowCreate: true})
|
||||
})
|
||||
}, func(tt *ui.Tester) {
|
||||
_ = tt.Click("Branch: main")
|
||||
tt.Type("ma")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// ChangedFile is a changed file in the tree or index.
|
||||
type ChangedFile struct {
|
||||
Path string
|
||||
Status GitStatus
|
||||
// Staged puts the file in the index group.
|
||||
Staged bool
|
||||
}
|
||||
|
||||
// ChangesListState is a ChangesList's selection and grouping.
|
||||
type ChangesListState struct {
|
||||
list ui.ListState
|
||||
cursor int
|
||||
// rows maps rows to files; negatives are group headers.
|
||||
rows []int
|
||||
staged int
|
||||
src *ChangedFile
|
||||
n int
|
||||
started bool
|
||||
}
|
||||
|
||||
// Selected returns the chosen file's index, or -1.
|
||||
func (s *ChangesListState) Selected() int {
|
||||
if s.cursor < 0 || s.cursor >= len(s.rows) {
|
||||
return -1
|
||||
}
|
||||
return s.rows[s.cursor]
|
||||
}
|
||||
|
||||
// ChangesListOptions configure a ChangesList.
|
||||
type ChangesListOptions struct {
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// ChangesList groups staged and unstaged files; give it a height.
|
||||
func ChangesList(c *ui.Context, s *ChangesListState, files []ChangedFile, opts ChangesListOptions) GitListResult {
|
||||
if !s.started {
|
||||
s.started, s.cursor = true, -1
|
||||
}
|
||||
var first *ChangedFile
|
||||
if len(files) > 0 {
|
||||
first = &files[0]
|
||||
}
|
||||
if first != s.src || len(files) != s.n {
|
||||
s.rows, s.staged = changesListRows(files)
|
||||
s.src, s.n, s.cursor = first, len(files), -1
|
||||
slog.Debug("mujica: changes list regroup", "files", len(files), "staged", s.staged)
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
var r GitListResult
|
||||
s.list.Selected = &s.cursor
|
||||
s.list.Header = func(i int) bool { return s.rows[i] < 0 }
|
||||
s.list.Key = func(i int) any {
|
||||
if f := s.rows[i]; f >= 0 {
|
||||
return fmt.Sprint(files[f].Staged, files[f].Path)
|
||||
}
|
||||
return s.rows[i]
|
||||
}
|
||||
s.list.Label = func(i int) string {
|
||||
if f := s.rows[i]; f >= 0 {
|
||||
return files[f].Path
|
||||
}
|
||||
return ""
|
||||
}
|
||||
prev := s.cursor
|
||||
outer := ui.Column(c).AlignItems(ui.Stretch).Disabled(opts.Disabled)
|
||||
var e *ui.Element
|
||||
outer.Children(func() {
|
||||
restore := data.DataSelectTheme(c, 0)
|
||||
defer restore()
|
||||
e = ui.List(c, &s.list, len(s.rows), func(i int) { changesListRow(c, s, files, i, &r) })
|
||||
e.Grow(1).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius)
|
||||
})
|
||||
r.Element, r.changed, r.submitted = outer, e.Changed(), e.Submitted()
|
||||
if outer.IsDisabled() {
|
||||
s.cursor, r.changed, r.submitted, r.action, r.item = prev, false, false, "", ""
|
||||
}
|
||||
if r.action != "" {
|
||||
slog.Debug("mujica: changes list action", "action", r.action, "path", r.item)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// changesListRow builds row i: a header or a file.
|
||||
func changesListRow(c *ui.Context, s *ChangesListState, files []ChangedFile, i int, r *GitListResult) {
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
u := d.Unit()
|
||||
f := s.rows[i]
|
||||
row := ui.Row(c).AlignItems(ui.Center).Gap(u*2).MinHeight(d.ControlHeight()).Padding(0, u*2, 0, u*3)
|
||||
if f < 0 {
|
||||
key, all, icon, count := "changes_list.staged", "unstage_all", "minus", s.staged
|
||||
if f == -2 {
|
||||
key, all, icon, count = "changes_list.unstaged", "stage_all", "plus", len(files)-s.staged
|
||||
}
|
||||
row.Background(k.Background).Children(func() {
|
||||
ui.Text(c, fmt.Sprintf(core.Msg(c, key), count)).Font(theme.SerifFont).FontWeight(600).
|
||||
FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.Ornament.Mix(k.Text, 0.35)).Grow(1)
|
||||
b := core.IconAction(c, icons.Must(icon), core.Msg(c, "changes_list."+all)).Disabled(count == 0)
|
||||
if b.Tooltip(core.Msg(c, "changes_list."+all)).Clicked() && !b.IsDisabled() {
|
||||
r.action = all
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
cf := files[f]
|
||||
if s.cursor == i {
|
||||
row.Draw(func(p *ui.Painter, rc ui.Rect) {
|
||||
p.Fill(ui.Rect{X: rc.X, Y: rc.Y + 4, W: 3, H: rc.H - 8}, k.AccentText, 1.5)
|
||||
})
|
||||
}
|
||||
row.Children(func() {
|
||||
GitStatusBadge(c, cf.Status, GitStatusBadgeOptions{})
|
||||
dir := path.Dir(cf.Path)
|
||||
ui.Text(c, path.Base(cf.Path)).SingleLine().Shrink(0).MaxWidth(240)
|
||||
if dir != "." {
|
||||
ui.Text(c, dir).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine().MinWidth(0)
|
||||
}
|
||||
ui.Spacer(c)
|
||||
id, icon := "stage", "plus"
|
||||
if cf.Staged {
|
||||
id, icon = "unstage", "minus"
|
||||
}
|
||||
b := core.IconAction(c, icons.Must(icon), core.Msg(c, "changes_list."+id)+" "+cf.Path).Tooltip(core.Msg(c, "changes_list."+id))
|
||||
if b.Clicked() && !b.IsDisabled() {
|
||||
r.action, r.item = id, cf.Path
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// changesListRows orders staged files, then unstaged, each under a header.
|
||||
func changesListRows(files []ChangedFile) ([]int, int) {
|
||||
rows := []int{-1}
|
||||
for i, f := range files {
|
||||
if f.Path == "" {
|
||||
panic("mujica: ChangedFile needs a Path")
|
||||
}
|
||||
gitStatusIndex(f.Status)
|
||||
if f.Staged {
|
||||
rows = append(rows, i)
|
||||
}
|
||||
}
|
||||
staged := len(rows) - 1
|
||||
rows = append(rows, -2)
|
||||
for i, f := range files {
|
||||
if !f.Staged {
|
||||
rows = append(rows, i)
|
||||
}
|
||||
}
|
||||
return rows, staged
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func changesSample() []ChangedFile {
|
||||
return []ChangedFile{
|
||||
{Path: "commit_graph.go", Status: GitAdded, Staged: true},
|
||||
{Path: "internal/git.go", Status: GitModified},
|
||||
{Path: "docs/old.md", Status: GitDeleted},
|
||||
{Path: "notes.txt", Status: GitUntracked},
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangesListGroupsAndStage(t *testing.T) {
|
||||
var s ChangesListState
|
||||
files := changesSample()
|
||||
var acted [][2]string
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := ChangesList(c, &s, files, ChangesListOptions{})
|
||||
r.Element.Height(320)
|
||||
if a, item, ok := r.Action(); ok {
|
||||
acted = append(acted, [2]string{a, item})
|
||||
}
|
||||
}, 600, 360)
|
||||
if !tt.HasText("Staged changes (1)") || !tt.HasText("Changes (3)") {
|
||||
t.Fatalf("groups: %q", tt.Texts())
|
||||
}
|
||||
for _, name := range []string{"Stage internal/git.go", "Unstage commit_graph.go", "Stage all", "Unstage all"} {
|
||||
if err := tt.Click(name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
want := [][2]string{{"stage", "internal/git.go"}, {"unstage", "commit_graph.go"}, {"stage_all", ""}, {"unstage_all", ""}}
|
||||
for i := range want {
|
||||
if i >= len(acted) || acted[i] != want[i] {
|
||||
t.Fatalf("actions %q, want %q", acted, want)
|
||||
}
|
||||
}
|
||||
if err := tt.Click("old.md"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := s.Selected(); got != 2 {
|
||||
t.Fatalf("selected %d, want 2", got)
|
||||
}
|
||||
tt.Key(0, ui.KeyDown)
|
||||
if got := s.Selected(); got != 3 {
|
||||
t.Fatalf("Down selected %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangesListDisabledAndEmptyGroups(t *testing.T) {
|
||||
var s ChangesListState
|
||||
files := []ChangedFile{{Path: "a.go", Status: GitModified}}
|
||||
acted := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := ChangesList(c, &s, files, ChangesListOptions{Disabled: true})
|
||||
r.Element.Height(200)
|
||||
_, _, ok := r.Action()
|
||||
acted = acted || ok || r.Changed()
|
||||
}, 500, 240)
|
||||
if !tt.HasText("Staged changes (0)") {
|
||||
t.Fatalf("empty group: %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Stage a.go")
|
||||
_ = tt.Click("a.go")
|
||||
if acted || s.Selected() != -1 {
|
||||
t.Fatal("disabled list acted")
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
var s ChangesListState
|
||||
ChangesList(c, &s, []ChangedFile{{Status: GitAdded}}, ChangesListOptions{})
|
||||
}, 300, 100, 1)
|
||||
}
|
||||
|
||||
func TestChangesListSnapshot(t *testing.T) {
|
||||
files := changesSample()
|
||||
snapshot(t, "changes_list", 520, 300, func(c *ui.Context) {
|
||||
s := ui.Local(c.Root(), "changes", func() *ChangesListState { return &ChangesListState{} })
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
ChangesList(c, *s, files, ChangesListOptions{}).Element.Height(260)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/agent"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// CommandBlockOptions configure a CommandBlock.
|
||||
type CommandBlockOptions struct {
|
||||
// Prompt precedes the command; empty means "$".
|
||||
Prompt string
|
||||
// MaxOutputHeight scrolls longer output; zero means 320 DIPs.
|
||||
MaxOutputHeight float32
|
||||
}
|
||||
|
||||
// CommandBlockResult is a built CommandBlock and its events.
|
||||
type CommandBlockResult struct {
|
||||
Element *ui.Element
|
||||
toggled bool
|
||||
copied string
|
||||
}
|
||||
|
||||
// Toggled reports the output was shown or hidden this frame.
|
||||
func (r CommandBlockResult) Toggled() bool { return r.toggled }
|
||||
|
||||
// Copied returns "command" or "output" when copied.
|
||||
func (r CommandBlockResult) Copied() (string, bool) { return r.copied, r.copied != "" }
|
||||
|
||||
// CommandBlock shows a command run; *open shows its output.
|
||||
func CommandBlock(c *ui.Context, run agent.CommandRun, open *bool, opts CommandBlockOptions) CommandBlockResult {
|
||||
if run.Command == "" {
|
||||
panic("mujica: CommandBlock needs a Command")
|
||||
}
|
||||
if run.Duration < 0 || opts.MaxOutputHeight < 0 {
|
||||
panic("mujica: CommandBlock Duration and MaxOutputHeight must not be negative")
|
||||
}
|
||||
prompt := opts.Prompt
|
||||
if prompt == "" {
|
||||
prompt = "$"
|
||||
}
|
||||
maxH := opts.MaxOutputHeight
|
||||
if maxH == 0 {
|
||||
maxH = 320
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
u := d.Unit()
|
||||
box := ui.Column(c).AlignItems(ui.Stretch).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.CardRadius).Clip()
|
||||
res := CommandBlockResult{Element: box}
|
||||
box.Children(func() {
|
||||
ui.Row(c).AlignItems(ui.Center).Gap(u).Padding(0, u*2, 0, 0).MinHeight(d.ControlHeight()).Children(func() {
|
||||
was := *open
|
||||
tr := ui.CollapsibleBase(c, open).Trigger
|
||||
if box.IsDisabled() {
|
||||
*open = was
|
||||
}
|
||||
if *open != was {
|
||||
res.toggled = true
|
||||
slog.Debug("mujica: command block toggle", "open", *open)
|
||||
}
|
||||
tr.Grow(1).MinWidth(0).MinHeight(d.ControlHeight()).AlignItems(ui.Center).Gap(u*2).Padding(0, u*3).
|
||||
Radius(theme.ControlRadius).Label(prompt + " " + run.Command).Cursor(ui.CursorPointer)
|
||||
internal.Ring(tr, k.Focus, theme.ControlRadius)
|
||||
if tr.Hovered() {
|
||||
tr.Background(k.SurfaceHover)
|
||||
}
|
||||
tr.Transition(core.ColorFade)
|
||||
turn := tr.Animate("mujica.turn", commandBlockTurn(*open), core.Motion(c, theme.StateDuration))
|
||||
tr.Children(func() {
|
||||
ui.Icon(c, icons.Must("chevron-right")).Size(14, 14).Shrink(0).Rotate(turn).TextColor(k.TextMuted)
|
||||
ui.Text(c, prompt).Font(theme.MonoFont).TextColor(k.Ornament.Mix(k.Text, 0.35)).Shrink(0)
|
||||
spans, _ := internal.CodeSpans(k, "shell", run.Command, false)
|
||||
ui.RichText(c, spans...).Font(theme.MonoFont).SingleLine().MinWidth(0)
|
||||
})
|
||||
commandBlockStatus(c, run)
|
||||
if core.IconAction(c, icons.Must("copy"), core.Msg(c, "command_block.copy_command")).Clicked() && !box.IsDisabled() {
|
||||
c.WriteClipboard(run.Command)
|
||||
res.copied = "command"
|
||||
}
|
||||
if core.IconAction(c, icons.Must("file-text"), core.Msg(c, "command_block.copy_output")).Disabled(run.Output == "").Clicked() && !box.IsDisabled() {
|
||||
c.WriteClipboard(internal.StripAnsi(run.Output))
|
||||
res.copied = "output"
|
||||
}
|
||||
})
|
||||
if *open {
|
||||
ui.Box(c).Height(1).Background(k.Border).Role(ui.RoleNone)
|
||||
out := ui.ScrollBoth(c).MaxHeight(maxH).Padding(u*3, u*4).Background(k.Background).Label(core.Msg(c, "command_block.output"))
|
||||
out.Children(func() {
|
||||
if run.Output == "" {
|
||||
ui.Text(c, core.Msg(c, "command_block.no_output")).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize))
|
||||
return
|
||||
}
|
||||
AnsiText(c, run.Output, AnsiTextOptions{})
|
||||
})
|
||||
}
|
||||
})
|
||||
if res.copied != "" {
|
||||
slog.Debug("mujica: command block copy", "what", res.copied)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// commandBlockStatus shows running, success or failure with icon and words.
|
||||
func commandBlockStatus(c *ui.Context, run agent.CommandRun) {
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
ui.Row(c).AlignItems(ui.Center).Gap(u * 1.5).Shrink(0).FontSize(core.FontSize(c, theme.CaptionSize)).Children(func() {
|
||||
if run.Running {
|
||||
core.Spinner(c, core.SpinnerOptions{Label: core.Msg(c, "command_block.running"), ShowLabel: true, Size: 12})
|
||||
return
|
||||
}
|
||||
icon, col := "circle-check", k.Success
|
||||
if run.ExitCode != 0 {
|
||||
icon, col = "circle-x", k.Danger
|
||||
}
|
||||
ui.Row(c).Gap(u).AlignItems(ui.Center).TextColor(col).Children(func() {
|
||||
ui.Icon(c, icons.Must(icon)).Size(14, 14)
|
||||
ui.Text(c, fmt.Sprintf(core.Msg(c, "command_block.exit"), run.ExitCode)).FontFeatures("tnum").SingleLine()
|
||||
})
|
||||
ui.Text(c, commandBlockDuration(run.Duration)).TextColor(k.TextMuted).FontFeatures("tnum").SingleLine()
|
||||
})
|
||||
}
|
||||
|
||||
func commandBlockTurn(open bool) float32 {
|
||||
if open {
|
||||
return 90
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// commandBlockDuration prints milliseconds, seconds or minutes.
|
||||
func commandBlockDuration(d time.Duration) string {
|
||||
if d < time.Second {
|
||||
return fmt.Sprintf("%d ms", d.Milliseconds())
|
||||
}
|
||||
if d < time.Minute {
|
||||
return fmt.Sprintf("%.1f s", d.Seconds())
|
||||
}
|
||||
return fmt.Sprintf("%dm %02ds", int(d.Minutes()), int(d.Seconds())%60)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/agent"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func commandBlockSample() agent.CommandRun {
|
||||
return agent.CommandRun{Command: "go test ./...", Output: "\x1b[32mok\x1b[0m mujicaui 3.2s", ExitCode: 0, Duration: 3200 * time.Millisecond}
|
||||
}
|
||||
|
||||
func TestCommandBlockToggleAndCopy(t *testing.T) {
|
||||
open, toggles := false, 0
|
||||
var copied []string
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := CommandBlock(c, commandBlockSample(), &open, CommandBlockOptions{})
|
||||
if r.Toggled() {
|
||||
toggles++
|
||||
}
|
||||
if w, ok := r.Copied(); ok {
|
||||
copied = append(copied, w)
|
||||
}
|
||||
}, 600, 300)
|
||||
if tt.HasText("mujicaui 3.2s") {
|
||||
t.Fatal("closed block shows output")
|
||||
}
|
||||
if !tt.HasText("exit 0") || !tt.HasText("3.2 s") {
|
||||
t.Fatalf("status: %q", tt.Texts())
|
||||
}
|
||||
if err := tt.Click("$ go test ./..."); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !open || toggles != 1 || !tt.HasText("ok mujicaui 3.2s") {
|
||||
t.Fatalf("open %v toggles %d texts %q", open, toggles, tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if open || toggles != 2 {
|
||||
t.Fatalf("Enter on the focused header: open %v toggles %d", open, toggles)
|
||||
}
|
||||
_ = tt.Click("Copy command")
|
||||
if tt.Clipboard() != "go test ./..." {
|
||||
t.Fatalf("clipboard %q", tt.Clipboard())
|
||||
}
|
||||
_ = tt.Click("Copy output")
|
||||
if tt.Clipboard() != "ok mujicaui 3.2s" || len(copied) != 2 || copied[1] != "output" {
|
||||
t.Fatalf("output copy %q %q", tt.Clipboard(), copied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandBlockFailureRunningDisabled(t *testing.T) {
|
||||
open := true
|
||||
run := agent.CommandRun{Command: "make", Output: "boom", ExitCode: 2, Duration: 90 * time.Second}
|
||||
disabled := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Disabled(disabled).Children(func() {
|
||||
CommandBlock(c, run, &open, CommandBlockOptions{Prompt: "❯"})
|
||||
})
|
||||
}, 600, 300)
|
||||
if !tt.HasText("exit 2") || !tt.HasText("1m 30s") {
|
||||
t.Fatalf("failure: %q", tt.Texts())
|
||||
}
|
||||
run.Running = true
|
||||
tt.Frame()
|
||||
if !tt.HasText("Running") || tt.HasText("exit 2") {
|
||||
t.Fatalf("running: %q", tt.Texts())
|
||||
}
|
||||
disabled = true
|
||||
tt.Frame()
|
||||
_ = tt.Click("❯ make")
|
||||
_ = tt.Click("Copy command")
|
||||
if !open || tt.Clipboard() != "" {
|
||||
t.Fatalf("disabled block acted: open %v clipboard %q", open, tt.Clipboard())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandBlockInvalid(t *testing.T) {
|
||||
for _, run := range []agent.CommandRun{{}, {Command: "x", Duration: -1}} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%+v did not panic", run)
|
||||
}
|
||||
}()
|
||||
open := false
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
CommandBlock(c, run, &open, CommandBlockOptions{})
|
||||
}, 100, 50, 1)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandBlockSnapshot(t *testing.T) {
|
||||
snapshot(t, "command_block", 620, 300, func(c *ui.Context) {
|
||||
a, b := true, false
|
||||
ui.Column(c).Padding(16).Gap(12).Children(func() {
|
||||
CommandBlock(c, agent.CommandRun{Command: "go test -run Git ./...", Output: "\x1b[32mok\x1b[0m \tmujicaui\t3.214s\n\x1b[33mwarn\x1b[0m slow test: TestCommitGraphVirtualized", Duration: 3214 * time.Millisecond}, &a, CommandBlockOptions{})
|
||||
CommandBlock(c, agent.CommandRun{Command: "git push origin main", ExitCode: 1, Duration: 640 * time.Millisecond, Output: "rejected"}, &b, CommandBlockOptions{})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// CommitGraphState is a CommitGraph's selection and its lane layout.
|
||||
type CommitGraphState struct {
|
||||
List data.ListState[string]
|
||||
rows []commitGraphRow
|
||||
// lanes is the widest the graph gets.
|
||||
lanes int
|
||||
src *Commit
|
||||
n int
|
||||
}
|
||||
|
||||
// CommitGraphOptions configure a CommitGraph.
|
||||
type CommitGraphOptions struct {
|
||||
// TimeFormat lays out commit times; empty means "2006-01-02 15:04".
|
||||
TimeFormat string
|
||||
// MaxLanes caps the graph width in lanes; zero means 8.
|
||||
MaxLanes int
|
||||
Status data.DataStatus
|
||||
Error string
|
||||
}
|
||||
|
||||
// commitGraphLink joins lane from to lane to within a row.
|
||||
type commitGraphLink struct{ from, to, color int }
|
||||
|
||||
type commitGraphRow struct {
|
||||
node, color int
|
||||
merge bool
|
||||
// top links enter from above; bottom links leave the node.
|
||||
top, bottom []commitGraphLink
|
||||
}
|
||||
|
||||
type commitGraphLane struct {
|
||||
hash string
|
||||
color int
|
||||
}
|
||||
|
||||
// CommitGraph shows commits with branch lanes; give it a height.
|
||||
func CommitGraph(c *ui.Context, s *CommitGraphState, commits []Commit, opts CommitGraphOptions) GitListResult {
|
||||
maxLanes := opts.MaxLanes
|
||||
if maxLanes == 0 {
|
||||
maxLanes = 8
|
||||
}
|
||||
if maxLanes < 0 {
|
||||
panic("mujica: CommitGraph MaxLanes is negative")
|
||||
}
|
||||
var first *Commit
|
||||
if len(commits) > 0 {
|
||||
first = &commits[0]
|
||||
}
|
||||
if first != s.src || len(commits) != s.n {
|
||||
s.rows, s.lanes = commitGraphLayout(commits)
|
||||
s.src, s.n = first, len(commits)
|
||||
slog.Debug("mujica: commit graph layout", "commits", len(commits), "lanes", s.lanes)
|
||||
}
|
||||
layout := gitTimeLayout(opts.TimeFormat)
|
||||
lane := core.Density(c).Unit() * 4
|
||||
width := float32(min(max(s.lanes, 1), maxLanes)) * lane
|
||||
h := core.Density(c).ControlHeight()
|
||||
v := data.List(c, &s.List, len(commits), data.ListOptions[string]{
|
||||
Key: func(i int) string { return gitCommitHash(commits[i]) },
|
||||
Label: func(i int) string { return commits[i].Subject },
|
||||
Status: opts.Status, Error: opts.Error, Empty: core.Msg(c, "commit_list.empty"),
|
||||
}, func(i int) {
|
||||
row := s.rows[i]
|
||||
ui.Box(c).Size(width, h).Shrink(0).Clip().Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
commitGraphDraw(p, core.Tokens(c), r, row, lane)
|
||||
})
|
||||
commitListRow(c, commits[i], layout)
|
||||
})
|
||||
res := GitListResult{Element: v.Element, changed: v.Changed(), submitted: v.Submitted()}
|
||||
if res.changed || res.submitted {
|
||||
key, _ := s.List.Selected()
|
||||
slog.Debug("mujica: commit graph choose", "hash", key, "open", res.submitted)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// commitGraphLayout gives each commit a lane and parent links.
|
||||
func commitGraphLayout(commits []Commit) ([]commitGraphRow, int) {
|
||||
rows := make([]commitGraphRow, len(commits))
|
||||
var lanes []commitGraphLane
|
||||
colors, widest := 0, 0
|
||||
free := func() int {
|
||||
for j, l := range lanes {
|
||||
if l.hash == "" {
|
||||
return j
|
||||
}
|
||||
}
|
||||
lanes = append(lanes, commitGraphLane{})
|
||||
return len(lanes) - 1
|
||||
}
|
||||
for i, cm := range commits {
|
||||
h := gitCommitHash(cm)
|
||||
var row commitGraphRow
|
||||
row.node = -1
|
||||
for j, l := range lanes {
|
||||
if l.hash == h && row.node < 0 {
|
||||
row.node = j
|
||||
}
|
||||
}
|
||||
for j, l := range lanes {
|
||||
switch {
|
||||
case l.hash == "":
|
||||
case l.hash == h:
|
||||
row.top = append(row.top, commitGraphLink{j, row.node, l.color})
|
||||
default:
|
||||
row.top = append(row.top, commitGraphLink{j, j, l.color})
|
||||
}
|
||||
}
|
||||
if row.node < 0 {
|
||||
row.node = free()
|
||||
lanes[row.node] = commitGraphLane{h, colors % 6}
|
||||
colors++
|
||||
}
|
||||
row.color = lanes[row.node].color
|
||||
for j := range lanes {
|
||||
if lanes[j].hash == h {
|
||||
lanes[j].hash = ""
|
||||
}
|
||||
}
|
||||
row.merge = len(cm.Parents) > 1
|
||||
for pi, p := range cm.Parents {
|
||||
if pi == 0 {
|
||||
lanes[row.node] = commitGraphLane{p, row.color}
|
||||
row.bottom = append(row.bottom, commitGraphLink{row.node, row.node, row.color})
|
||||
continue
|
||||
}
|
||||
to := -1
|
||||
for j, l := range lanes {
|
||||
if l.hash == p {
|
||||
to = j
|
||||
break
|
||||
}
|
||||
}
|
||||
if to < 0 {
|
||||
to = free()
|
||||
lanes[to] = commitGraphLane{p, colors % 6}
|
||||
colors++
|
||||
}
|
||||
row.bottom = append(row.bottom, commitGraphLink{row.node, to, lanes[to].color})
|
||||
}
|
||||
for len(lanes) > 0 && lanes[len(lanes)-1].hash == "" {
|
||||
lanes = lanes[:len(lanes)-1]
|
||||
}
|
||||
widest = max(widest, len(lanes), row.node+1)
|
||||
rows[i] = row
|
||||
}
|
||||
return rows, widest
|
||||
}
|
||||
|
||||
// commitGraphColors are the lane colors; place also tells lanes apart.
|
||||
func commitGraphColors(k theme.Tokens) [6]ui.Color {
|
||||
return [6]ui.Color{k.AccentText, k.Info, k.Success, k.Warning, k.Danger, k.TextMuted}
|
||||
}
|
||||
|
||||
func commitGraphDraw(p *ui.Painter, k theme.Tokens, r ui.Rect, row commitGraphRow, lane float32) {
|
||||
cols := commitGraphColors(k)
|
||||
x := func(j int) float32 { return internal.Snap(p, r.X+lane/2+float32(j)*lane) }
|
||||
top, mid, bot := internal.Snap(p, r.Y), internal.Snap(p, r.Y+r.H/2), internal.Snap(p, r.Y+r.H)
|
||||
seg := func(l commitGraphLink, y0, y1 float32) {
|
||||
var path ui.Path
|
||||
xa, xb := x(l.from), x(l.to)
|
||||
path.MoveTo(xa, y0)
|
||||
if xa == xb {
|
||||
path.LineTo(xb, y1)
|
||||
} else {
|
||||
m := (y0 + y1) / 2
|
||||
path.CubeTo(xa, m, xb, m, xb, y1)
|
||||
}
|
||||
p.StrokePath(&path, 2, cols[l.color])
|
||||
}
|
||||
for _, l := range row.top {
|
||||
if l.from == l.to && l.to != row.node {
|
||||
seg(l, top, bot)
|
||||
continue
|
||||
}
|
||||
seg(l, top, mid)
|
||||
}
|
||||
for _, l := range row.bottom {
|
||||
seg(l, mid, bot)
|
||||
}
|
||||
cx, col := x(row.node), cols[row.color]
|
||||
if row.merge {
|
||||
p.FillPath(internal.Diamond(cx, mid, 11), k.Surface)
|
||||
p.StrokePath(internal.Diamond(cx, mid, 9), 2, col)
|
||||
return
|
||||
}
|
||||
var dot ui.Path
|
||||
dot.Circle(cx, mid, 5.5)
|
||||
p.FillPath(&dot, k.Surface)
|
||||
var ring ui.Path
|
||||
ring.Circle(cx, mid, 4)
|
||||
p.FillPath(&ring, col)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
// commitGraphSample is main with a feature branch merged back.
|
||||
func commitGraphSample() []Commit {
|
||||
cs := []Commit{
|
||||
{Hash: "m3", Parents: []string{"m2", "f2"}, Subject: "Merge feature", Refs: []string{"main"}},
|
||||
{Hash: "f2", Parents: []string{"f1"}, Subject: "Feature work"},
|
||||
{Hash: "m2", Parents: []string{"m1"}, Subject: "Fix on main"},
|
||||
{Hash: "f1", Parents: []string{"m1"}, Subject: "Start feature"},
|
||||
{Hash: "m1", Subject: "Initial commit"},
|
||||
}
|
||||
for i := range cs {
|
||||
cs[i].Author = []string{"Sakiko", "Mutsumi", "Uika"}[i%3]
|
||||
cs[i].When = gitTestTime.Add(-time.Duration(i) * 3 * time.Hour)
|
||||
}
|
||||
return cs
|
||||
}
|
||||
|
||||
func TestCommitGraphLayoutLanes(t *testing.T) {
|
||||
rows, lanes := commitGraphLayout(commitGraphSample())
|
||||
if lanes != 2 {
|
||||
t.Fatalf("lanes %d, want 2", lanes)
|
||||
}
|
||||
want := []struct {
|
||||
node int
|
||||
merge bool
|
||||
}{{0, true}, {1, false}, {0, false}, {1, false}, {0, false}}
|
||||
for i, w := range want {
|
||||
if rows[i].node != w.node || rows[i].merge != w.merge {
|
||||
t.Errorf("row %d: node %d merge %v, want %+v", i, rows[i].node, rows[i].merge, w)
|
||||
}
|
||||
}
|
||||
// The merge sends a line to the feature lane; f1 joins main again at m1.
|
||||
if l := rows[0].bottom; len(l) != 2 || l[1].from != 0 || l[1].to != 1 {
|
||||
t.Errorf("merge links %+v", l)
|
||||
}
|
||||
if l := rows[4].top; len(l) != 2 || l[1].from != 1 || l[1].to != 0 {
|
||||
t.Errorf("fork point links %+v", l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitGraphVirtualized(t *testing.T) {
|
||||
commits := gitHistory(100000)
|
||||
var s CommitGraphState
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
CommitGraph(c, &s, commits, CommitGraphOptions{}).Element.Height(400)
|
||||
}, 700, 440)
|
||||
if n := len(tt.Texts()); n > 400 {
|
||||
t.Fatalf("%d texts built for 100,000 commits", n)
|
||||
}
|
||||
rows := &s.rows[0]
|
||||
tt.Scroll(300, 200, 0, 50000)
|
||||
tt.Frame()
|
||||
if &s.rows[0] != rows {
|
||||
t.Fatal("layout recomputed without a data change")
|
||||
}
|
||||
if tt.HasText("Commit number 100000") {
|
||||
t.Fatal("scroll did not move the window of rows")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitGraphChooseAndKeys(t *testing.T) {
|
||||
var s CommitGraphState
|
||||
changes := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := CommitGraph(c, &s, commitGraphSample(), CommitGraphOptions{})
|
||||
r.Element.Height(300)
|
||||
if r.Changed() {
|
||||
changes++
|
||||
}
|
||||
}, 700, 400)
|
||||
if err := tt.Click("Fix on main"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Key(0, ui.KeyDown)
|
||||
if key, _ := s.List.Selected(); key != "f1" || changes != 2 {
|
||||
t.Fatalf("selected %q changes %d", key, changes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitGraphSnapshot(t *testing.T) {
|
||||
commits := commitGraphSample()
|
||||
snapshot(t, "commit_graph", 640, 240, func(c *ui.Context) {
|
||||
s := ui.Local(c.Root(), "graph", func() *CommitGraphState { return &CommitGraphState{} })
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
CommitGraph(c, *s, commits, CommitGraphOptions{}).Element.Height(200)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// CommitMessage is a commit's title and body.
|
||||
type CommitMessage struct {
|
||||
Title string
|
||||
Body string
|
||||
}
|
||||
|
||||
// CommitInputOptions configure a CommitInput.
|
||||
type CommitInputOptions struct {
|
||||
// TitleLimit warns past this many title characters; zero means 50.
|
||||
TitleLimit int
|
||||
// Busy shows a commit in progress and blocks another.
|
||||
Busy bool
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// CommitInputResult is a built CommitInput and its events.
|
||||
type CommitInputResult struct {
|
||||
Element *ui.Element
|
||||
committed, amend bool
|
||||
}
|
||||
|
||||
// Committed reports Commit, or Cmd+Enter, this frame.
|
||||
func (r CommitInputResult) Committed() bool { return r.committed }
|
||||
|
||||
// Amended reports Amend this frame.
|
||||
func (r CommitInputResult) Amended() bool { return r.amend }
|
||||
|
||||
type commitInputState struct {
|
||||
// pending blocks commits until Busy comes and goes.
|
||||
pending, sawBusy bool
|
||||
}
|
||||
|
||||
// CommitInput edits a commit message and commits or amends once.
|
||||
func CommitInput(c *ui.Context, m *CommitMessage, opts CommitInputOptions) CommitInputResult {
|
||||
limit := opts.TitleLimit
|
||||
if limit == 0 {
|
||||
limit = 50
|
||||
}
|
||||
if limit < 0 {
|
||||
panic("mujica: CommitInput TitleLimit is negative")
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
col := ui.Column(c).AlignItems(ui.Stretch).Gap(u * 2).Disabled(opts.Disabled)
|
||||
res := CommitInputResult{Element: col}
|
||||
st := ui.Local(col, "state", func() *commitInputState { return &commitInputState{} })
|
||||
s := *st
|
||||
if opts.Busy {
|
||||
s.sawBusy = true
|
||||
} else if s.sawBusy {
|
||||
s.pending, s.sawBusy = false, false
|
||||
}
|
||||
blocked := opts.Busy || s.pending
|
||||
n := utf8.RuneCountInString(m.Title)
|
||||
col.Children(func() {
|
||||
title := input.TextInput(c, &m.Title, input.TextInputOptions{Placeholder: core.Msg(c, "commit_input.title"), Label: core.Msg(c, "commit_input.title"),
|
||||
Disabled: opts.Disabled, ReadOnly: blocked,
|
||||
Trailing: func() {
|
||||
col := k.TextMuted
|
||||
if n > limit {
|
||||
col = k.Warning
|
||||
}
|
||||
name := fmt.Sprintf(core.Msg(c, "commit_input.count"), n, limit)
|
||||
if n > limit {
|
||||
name = fmt.Sprintf(core.Msg(c, "commit_input.long"), n-limit)
|
||||
}
|
||||
ui.Text(c, fmt.Sprintf("%d/%d", n, limit)).FontSize(core.FontSize(c, theme.CaptionSize)).FontFeatures("tnum").TextColor(col).Shrink(0).
|
||||
Label(name).Tooltip(name)
|
||||
}})
|
||||
body := input.TextArea(c, &m.Body, input.TextAreaOptions{Placeholder: core.Msg(c, "commit_input.body"), Label: core.Msg(c, "commit_input.body"),
|
||||
MinHeight: 72, MaxHeight: 200, Disabled: opts.Disabled, ReadOnly: blocked})
|
||||
empty := strings.TrimSpace(m.Title) == ""
|
||||
send := (title.Submitted() || body.Submitted()) && !empty
|
||||
ui.Row(c).Gap(u * 2).Justify(ui.End).Children(func() {
|
||||
if input.Button(c, core.Msg(c, "commit_input.amend"), input.ButtonOptions{Variant: input.Secondary, Icon: icons.Must("rotate-ccw"), Disabled: blocked || empty}).Clicked() {
|
||||
res.amend = true
|
||||
}
|
||||
if input.Button(c, core.Msg(c, "commit_input.commit"), input.ButtonOptions{Icon: icons.Must("check"), Loading: opts.Busy, Disabled: s.pending || empty}).Clicked() {
|
||||
send = true
|
||||
}
|
||||
})
|
||||
res.committed = send && !res.amend
|
||||
})
|
||||
if blocked || col.IsDisabled() {
|
||||
res.committed, res.amend = false, false
|
||||
}
|
||||
if res.committed || res.amend {
|
||||
s.pending = true
|
||||
slog.Debug("mujica: commit input submit", "amend", res.amend)
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestCommitInputCommitOnceWhileBusy(t *testing.T) {
|
||||
m := CommitMessage{}
|
||||
busy := false
|
||||
commits, amends := 0, 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := CommitInput(c, &m, CommitInputOptions{Busy: busy})
|
||||
if r.Committed() {
|
||||
commits++
|
||||
}
|
||||
if r.Amended() {
|
||||
amends++
|
||||
}
|
||||
}, 600, 400)
|
||||
_ = tt.Click("Commit")
|
||||
if commits != 0 {
|
||||
t.Fatal("empty title committed")
|
||||
}
|
||||
if err := tt.Click("Summary"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Type("Add git components")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
_ = tt.Click("Commit")
|
||||
if commits != 1 {
|
||||
t.Fatalf("commits %d, want 1 before Busy clears", commits)
|
||||
}
|
||||
busy = true
|
||||
tt.Frame()
|
||||
_ = tt.Click("Commit")
|
||||
_ = tt.Click("Amend")
|
||||
busy = false
|
||||
tt.Frame()
|
||||
if commits != 1 || amends != 0 {
|
||||
t.Fatalf("busy: commits %d amends %d", commits, amends)
|
||||
}
|
||||
_ = tt.Click("Amend")
|
||||
if amends != 1 {
|
||||
t.Fatalf("amends %d", amends)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitInputTitleHint(t *testing.T) {
|
||||
m := CommitMessage{Title: strings.Repeat("x", 55)}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
CommitInput(c, &m, CommitInputOptions{})
|
||||
}, 600, 400)
|
||||
if _, ok := tt.Find("Summary is 5 characters over the suggested length"); !ok {
|
||||
t.Fatal("over-length hint not named")
|
||||
}
|
||||
m.Title = "short"
|
||||
tt.Frame()
|
||||
if _, ok := tt.Find("5 of 50 characters"); !ok {
|
||||
t.Fatal("count not named")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitInputDisabledAndInvalid(t *testing.T) {
|
||||
m := CommitMessage{Title: "x"}
|
||||
acted := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := CommitInput(c, &m, CommitInputOptions{Disabled: true})
|
||||
acted = acted || r.Committed() || r.Amended()
|
||||
}, 600, 400)
|
||||
_ = tt.Click("Commit")
|
||||
_ = tt.Click("Amend")
|
||||
if acted {
|
||||
t.Fatal("disabled input committed")
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
CommitInput(c, &m, CommitInputOptions{TitleLimit: -1})
|
||||
}, 300, 100, 1)
|
||||
}
|
||||
|
||||
func TestCommitInputSnapshot(t *testing.T) {
|
||||
snapshot(t, "commit_input", 520, 280, func(c *ui.Context) {
|
||||
m := ui.Local(c.Root(), "msg", func() CommitMessage {
|
||||
return CommitMessage{Title: "Add CommitGraph lanes with merge lines", Body: "Lanes are laid out once per history."}
|
||||
})
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
CommitInput(c, m, CommitInputOptions{})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// Commit is one commit of a history, newest first.
|
||||
type Commit struct {
|
||||
Hash string
|
||||
Parents []string
|
||||
Author string
|
||||
When time.Time
|
||||
Subject string
|
||||
// Refs are branch and tag names pointing at the commit.
|
||||
Refs []string
|
||||
}
|
||||
|
||||
// GitListResult is a built version-control list and its events.
|
||||
type GitListResult struct {
|
||||
Element *ui.Element
|
||||
changed, submitted bool
|
||||
action, item string
|
||||
}
|
||||
|
||||
// Changed reports a new selection.
|
||||
func (r GitListResult) Changed() bool { return r.changed }
|
||||
|
||||
// Submitted reports Enter or a double click on a row.
|
||||
func (r GitListResult) Submitted() bool { return r.submitted }
|
||||
|
||||
// Action returns this frame's action ID and its item.
|
||||
func (r GitListResult) Action() (action, item string, ok bool) {
|
||||
return r.action, r.item, r.action != ""
|
||||
}
|
||||
|
||||
// CommitListOptions configure a CommitList.
|
||||
type CommitListOptions struct {
|
||||
// TimeFormat lays out commit times; empty means "2006-01-02 15:04".
|
||||
TimeFormat string
|
||||
Status data.DataStatus
|
||||
Error string
|
||||
}
|
||||
|
||||
// CommitList shows commits to choose from; give it a height.
|
||||
func CommitList(c *ui.Context, s *data.ListState[string], commits []Commit, opts CommitListOptions) GitListResult {
|
||||
layout := gitTimeLayout(opts.TimeFormat)
|
||||
v := data.List(c, s, len(commits), data.ListOptions[string]{
|
||||
Key: func(i int) string { return gitCommitHash(commits[i]) },
|
||||
Label: func(i int) string { return commits[i].Subject },
|
||||
Status: opts.Status, Error: opts.Error, Empty: core.Msg(c, "commit_list.empty"),
|
||||
}, func(i int) { commitListRow(c, commits[i], layout) })
|
||||
r := GitListResult{Element: v.Element, changed: v.Changed(), submitted: v.Submitted()}
|
||||
if r.changed || r.submitted {
|
||||
key, _ := s.Selected()
|
||||
slog.Debug("mujica: commit list choose", "hash", key, "open", r.submitted)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func commitListRow(c *ui.Context, cm Commit, layout string) {
|
||||
k := core.Tokens(c)
|
||||
gitRefs(c, cm.Refs)
|
||||
ui.Text(c, cm.Subject).SingleLine().Grow(1).MinWidth(0)
|
||||
small := core.FontSize(c, theme.CaptionSize)
|
||||
ui.Text(c, cm.Author).FontSize(small).TextColor(k.TextMuted).SingleLine().MaxWidth(140).Shrink(0)
|
||||
ui.Text(c, gitShortHash(cm.Hash)).Font(theme.MonoFont).FontSize(small).TextColor(k.AccentText).Shrink(0)
|
||||
ui.Text(c, cm.When.Format(layout)).FontSize(small).FontFeatures("tnum").TextColor(k.TextMuted).Shrink(0)
|
||||
}
|
||||
|
||||
// gitRefs shows branch and tag names as small outlined pills.
|
||||
func gitRefs(c *ui.Context, refs []string) {
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
for _, ref := range refs {
|
||||
ui.Text(c, ref).FontSize(core.FontSize(c, theme.CaptionSize)).FontWeight(600).TextColor(k.AccentText).SingleLine().
|
||||
Padding(0, u*1.5).Radius(theme.ControlRadius).Border(theme.BorderWidth, k.AccentText).Shrink(0).MaxWidth(160)
|
||||
}
|
||||
}
|
||||
|
||||
func gitCommitHash(cm Commit) string {
|
||||
if cm.Hash == "" {
|
||||
panic("mujica: Commit needs a Hash")
|
||||
}
|
||||
return cm.Hash
|
||||
}
|
||||
|
||||
// gitShortHash is the first seven characters of a hash.
|
||||
func gitShortHash(h string) string {
|
||||
if len(h) > 7 {
|
||||
return h[:7]
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func gitTimeLayout(f string) string {
|
||||
if f == "" {
|
||||
return "2006-01-02 15:04"
|
||||
}
|
||||
return f
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
)
|
||||
|
||||
var gitTestTime = time.Date(2026, 10, 5, 9, 30, 0, 0, time.UTC)
|
||||
|
||||
// gitTestHash is a stable, hash-like hex string for commit i.
|
||||
func gitTestHash(i int) string { return fmt.Sprintf("%016x%024x", uint64(i)*0x9E3779B97F4A7C15, i) }
|
||||
|
||||
// gitHistory is a linear history of n commits, newest first.
|
||||
func gitHistory(n int) []Commit {
|
||||
out := make([]Commit, n)
|
||||
for i := range out {
|
||||
out[i] = Commit{Hash: gitTestHash(n - i), Author: "Sakiko", When: gitTestTime.Add(-time.Duration(i) * time.Hour),
|
||||
Subject: fmt.Sprintf("Commit number %d", n-i)}
|
||||
if i+1 < n {
|
||||
out[i].Parents = []string{gitTestHash(n - i - 1)}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestCommitListChooseAndOpen(t *testing.T) {
|
||||
commits := gitHistory(5)
|
||||
commits[0].Refs = []string{"main"}
|
||||
var s data.ListState[string]
|
||||
changes, opens := 0, 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := CommitList(c, &s, commits, CommitListOptions{})
|
||||
r.Element.Height(300)
|
||||
if r.Changed() {
|
||||
changes++
|
||||
}
|
||||
if r.Submitted() {
|
||||
opens++
|
||||
}
|
||||
}, 700, 340)
|
||||
if !tt.HasText("main") || !tt.HasText("1715609") || !tt.HasText("2026-10-05 09:30") {
|
||||
t.Fatalf("row parts: %q", tt.Texts())
|
||||
}
|
||||
if err := tt.Click("Commit number 3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if key, _ := s.Selected(); key != commits[2].Hash || changes != 1 {
|
||||
t.Fatalf("selected %q changes %d", key, changes)
|
||||
}
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if key, _ := s.Selected(); key != commits[3].Hash || opens != 1 {
|
||||
t.Fatalf("keyboard: %q opens %d", key, opens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitListEmptyAndInvalid(t *testing.T) {
|
||||
var s data.ListState[string]
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
CommitList(c, &s, nil, CommitListOptions{})
|
||||
}, 400, 200)
|
||||
if !tt.HasText("No commits") {
|
||||
t.Fatalf("empty: %q", tt.Texts())
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("hashless commit did not panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
var s data.ListState[string]
|
||||
CommitList(c, &s, []Commit{{Subject: "x"}}, CommitListOptions{})
|
||||
}, 300, 100, 1)
|
||||
}
|
||||
|
||||
func TestCommitListSnapshot(t *testing.T) {
|
||||
commits := gitHistory(4)
|
||||
commits[0].Refs = []string{"main", "v1.1"}
|
||||
snapshot(t, "commit_list", 640, 220, func(c *ui.Context) {
|
||||
s := ui.Local(c.Root(), "list", func() *data.ListState[string] { return &data.ListState[string]{} })
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
CommitList(c, *s, commits, CommitListOptions{}).Element.Height(180)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// ConflictResolverState holds conflict blocks and their choices.
|
||||
type ConflictResolverState struct {
|
||||
text string
|
||||
parts []conflictPart
|
||||
choices []MergeChoice
|
||||
bad int
|
||||
built bool
|
||||
}
|
||||
|
||||
// conflictPart is plain text or a conflict block.
|
||||
type conflictPart struct {
|
||||
lines []string
|
||||
ours, theirs []string
|
||||
oursRef, theirsRef string
|
||||
line int
|
||||
conflict bool
|
||||
}
|
||||
|
||||
// Resolved returns chosen blocks and all blocks.
|
||||
func (s *ConflictResolverState) Resolved() (done, total int) {
|
||||
for i, p := range s.parts {
|
||||
if p.conflict {
|
||||
total++
|
||||
if s.choices[i] != MergeUnresolved {
|
||||
done++
|
||||
}
|
||||
}
|
||||
}
|
||||
return done, total
|
||||
}
|
||||
|
||||
// Result applies the choices; unresolved blocks keep markers.
|
||||
func (s *ConflictResolverState) Result() string {
|
||||
var out []string
|
||||
for i, p := range s.parts {
|
||||
if !p.conflict {
|
||||
out = append(out, p.lines...)
|
||||
continue
|
||||
}
|
||||
switch s.choices[i] {
|
||||
case MergeOurs:
|
||||
out = append(out, p.ours...)
|
||||
case MergeTheirs:
|
||||
out = append(out, p.theirs...)
|
||||
case MergeBoth:
|
||||
out = append(append(out, p.ours...), p.theirs...)
|
||||
default:
|
||||
out = append(out, p.lines...)
|
||||
}
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
// ConflictResolverOptions configure a ConflictResolver.
|
||||
type ConflictResolverOptions struct {
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// ConflictResolver resolves the git conflict blocks of text.
|
||||
func ConflictResolver(c *ui.Context, s *ConflictResolverState, text string, opts ConflictResolverOptions) core.ChoiceResult {
|
||||
if !s.built || s.text != text {
|
||||
s.parts, s.bad = conflictParse(internal.SplitLines(text))
|
||||
s.choices = make([]MergeChoice, len(s.parts))
|
||||
s.text, s.built = text, true
|
||||
_, total := s.Resolved()
|
||||
slog.Debug("mujica: conflict resolver parse", "conflicts", total, "badLine", s.bad)
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
col := ui.Column(c).AlignItems(ui.Stretch).Gap(u * 3).Disabled(opts.Disabled)
|
||||
res := core.ChoiceResult{Element: col}
|
||||
col.Children(func() {
|
||||
done, total := s.Resolved()
|
||||
ui.Row(c).AlignItems(ui.Center).Gap(u * 3).Children(func() {
|
||||
ui.Text(c, fmt.Sprintf(core.Msg(c, "conflict_resolver.resolved"), done, total)).FontWeight(600).Role(ui.RoleStatus)
|
||||
if total > 0 {
|
||||
ui.Box(c).Height(4).Grow(1).Radius(2).Background(k.Border).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * float32(done) / float32(total), H: r.H}, k.Success, 2)
|
||||
})
|
||||
}
|
||||
})
|
||||
if s.bad > 0 {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).TextColor(k.Danger).Children(func() {
|
||||
ui.Icon(c, icons.Must("circle-alert")).Size(16, 16)
|
||||
ui.Text(c, fmt.Sprintf(core.Msg(c, "conflict_resolver.broken"), s.bad)).Role(ui.RoleStatus)
|
||||
})
|
||||
}
|
||||
if total == 0 && s.bad == 0 {
|
||||
ui.Text(c, core.Msg(c, "conflict_resolver.none")).TextColor(k.TextMuted)
|
||||
}
|
||||
n := 0
|
||||
for i, p := range s.parts {
|
||||
if !p.conflict {
|
||||
continue
|
||||
}
|
||||
n++
|
||||
if conflictBlock(c, s, i, n) && !col.IsDisabled() {
|
||||
res = core.NewChoiceResult(res.Element, true)
|
||||
}
|
||||
}
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
// conflictBlock shows one conflict and its choice buttons.
|
||||
func conflictBlock(c *ui.Context, s *ConflictResolverState, i, n int) bool {
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
p := s.parts[i]
|
||||
choice := s.choices[i]
|
||||
changed := false
|
||||
card := ui.Column(c).AlignItems(ui.Stretch).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.CardRadius).Clip()
|
||||
card.Children(func() {
|
||||
ui.Row(c).AlignItems(ui.Center).Gap(u*2).Padding(u*2, u*3).Background(k.Background).Children(func() {
|
||||
icon, colr, key := "triangle-alert", k.Warning, "conflict_resolver.open"
|
||||
if choice != MergeUnresolved {
|
||||
icon, colr, key = "circle-check", k.Success, "conflict_resolver.done"
|
||||
}
|
||||
ui.Icon(c, icons.Must(icon)).Size(14, 14).TextColor(colr)
|
||||
ui.Text(c, fmt.Sprintf(core.Msg(c, "conflict_resolver.title"), n, p.line)).Font(theme.SerifFont).FontWeight(600)
|
||||
ui.Text(c, core.Msg(c, key)).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(colr)
|
||||
})
|
||||
ui.Grid(c).ColumnTracks(ui.Fr(1), ui.Fr(1)).Children(func() {
|
||||
side := func(title, ref string, lines []string, col ui.Color, on bool) {
|
||||
cell := ui.Column(c).Gap(u).Padding(u*2, u*3).MinWidth(0).Clip()
|
||||
if on {
|
||||
cell.Background(col.Mix(k.Surface, 0.88))
|
||||
}
|
||||
cell.Children(func() {
|
||||
ui.Row(c).Gap(u).AlignItems(ui.Center).Children(func() {
|
||||
if on {
|
||||
ui.Icon(c, icons.Must("check")).Size(14, 14).TextColor(k.AccentText)
|
||||
}
|
||||
ui.Text(c, title).FontSize(core.FontSize(c, theme.CaptionSize)).FontWeight(600).TextColor(col)
|
||||
ui.Text(c, ref).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
|
||||
})
|
||||
mergeLines(c, lines, false)
|
||||
})
|
||||
}
|
||||
side(core.Msg(c, "conflict_resolver.current"), p.oursRef, p.ours, k.Info, choice == MergeOurs || choice == MergeBoth)
|
||||
side(core.Msg(c, "conflict_resolver.incoming"), p.theirsRef, p.theirs, k.Warning, choice == MergeTheirs || choice == MergeBoth)
|
||||
})
|
||||
ui.Row(c).Gap(u*2).Padding(u*2, u*3).Wrap().Children(func() {
|
||||
for _, opt := range []struct {
|
||||
choice MergeChoice
|
||||
key string
|
||||
}{{MergeOurs, "take_current"}, {MergeTheirs, "take_incoming"}, {MergeBoth, "take_both"}, {MergeUnresolved, "reset"}} {
|
||||
if opt.choice == MergeUnresolved && choice == MergeUnresolved {
|
||||
continue
|
||||
}
|
||||
if mergeChoiceButton(c, core.Msg(c, "conflict_resolver."+opt.key), choice == opt.choice && opt.choice != MergeUnresolved).Clicked() && choice != opt.choice {
|
||||
s.choices[i], changed = opt.choice, true
|
||||
slog.Debug("mujica: conflict resolver choose", "block", n, "choice", opt.key)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
return changed
|
||||
}
|
||||
|
||||
// conflictParse splits at markers; bad is an unclosed block's line.
|
||||
func conflictParse(lines []string) (parts []conflictPart, bad int) {
|
||||
var plain []string
|
||||
for i := 0; i < len(lines); i++ {
|
||||
if !strings.HasPrefix(lines[i], "<<<<<<<") {
|
||||
plain = append(plain, lines[i])
|
||||
continue
|
||||
}
|
||||
start := i
|
||||
p := conflictPart{conflict: true, line: i + 1, oursRef: strings.TrimSpace(lines[i][7:])}
|
||||
stage, j := 0, i+1
|
||||
for ; j < len(lines); j++ {
|
||||
l := lines[j]
|
||||
switch {
|
||||
case stage == 0 && strings.HasPrefix(l, "|||||||"):
|
||||
stage = 1
|
||||
case stage < 2 && l == "=======":
|
||||
stage = 2
|
||||
case stage == 2 && strings.HasPrefix(l, ">>>>>>>"):
|
||||
p.theirsRef = strings.TrimSpace(l[7:])
|
||||
stage = 3
|
||||
case stage == 0:
|
||||
p.ours = append(p.ours, l)
|
||||
case stage == 2:
|
||||
p.theirs = append(p.theirs, l)
|
||||
}
|
||||
if stage == 3 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if stage != 3 {
|
||||
return []conflictPart{{lines: lines}}, start + 1
|
||||
}
|
||||
if len(plain) > 0 {
|
||||
parts = append(parts, conflictPart{lines: plain})
|
||||
plain = nil
|
||||
}
|
||||
p.lines = lines[start : j+1]
|
||||
parts = append(parts, p)
|
||||
i = j
|
||||
}
|
||||
if len(plain) > 0 {
|
||||
parts = append(parts, conflictPart{lines: plain})
|
||||
}
|
||||
return parts, 0
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
const conflictText = "intro\n<<<<<<< HEAD\nours one\n=======\ntheirs one\n>>>>>>> feature\nmiddle\n<<<<<<< HEAD\nours two\n||||||| base\nold two\n=======\ntheirs two\n>>>>>>> feature\nend"
|
||||
|
||||
func TestConflictParse(t *testing.T) {
|
||||
parts, bad := conflictParse([]string{"a", "<<<<<<< HEAD", "x", "=======", "y", ">>>>>>> br", "b"})
|
||||
if bad != 0 || len(parts) != 3 || !parts[1].conflict || parts[1].oursRef != "HEAD" || parts[1].theirsRef != "br" || parts[1].line != 2 {
|
||||
t.Fatalf("parts %+v bad %d", parts, bad)
|
||||
}
|
||||
parts, bad = conflictParse([]string{"a", "<<<<<<< HEAD", "x", "======="})
|
||||
if bad != 2 || len(parts) != 1 || parts[0].conflict {
|
||||
t.Fatalf("unclosed: %+v bad %d", parts, bad)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConflictResolverAcceptAndCount(t *testing.T) {
|
||||
var s ConflictResolverState
|
||||
changes := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Scroll(c).Fill().Children(func() {
|
||||
if ConflictResolver(c, &s, conflictText, ConflictResolverOptions{}).Changed() {
|
||||
changes++
|
||||
}
|
||||
})
|
||||
}, 800, 900)
|
||||
if !tt.HasText("0 of 2 conflicts resolved") || !tt.HasText("Conflict 2 · line 8") {
|
||||
t.Fatalf("start: %q", tt.Texts())
|
||||
}
|
||||
if err := tt.Click("Accept incoming"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tt.HasText("1 of 2 conflicts resolved") || changes != 1 {
|
||||
t.Fatalf("after one: %q", tt.Texts())
|
||||
}
|
||||
if err := tt.Click("Accept both"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := s.Result(); got != "intro\nours one\ntheirs one\nmiddle\n<<<<<<< HEAD\nours two\n||||||| base\nold two\n=======\ntheirs two\n>>>>>>> feature\nend" {
|
||||
t.Fatalf("result %q", got)
|
||||
}
|
||||
if err := tt.Click("Undo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d, n := s.Resolved(); d != 0 || n != 2 || changes != 3 {
|
||||
t.Fatalf("undo: %d/%d changes %d", d, n, changes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConflictResolverBrokenAndDisabled(t *testing.T) {
|
||||
var s ConflictResolverState
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ConflictResolver(c, &s, "a\n<<<<<<< HEAD\nx", ConflictResolverOptions{})
|
||||
}, 600, 300)
|
||||
if !tt.HasText("Conflict markers are not closed after line 2") {
|
||||
t.Fatalf("broken: %q", tt.Texts())
|
||||
}
|
||||
if s.Result() != "a\n<<<<<<< HEAD\nx" {
|
||||
t.Fatalf("broken result %q", s.Result())
|
||||
}
|
||||
var d ConflictResolverState
|
||||
tt = ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ConflictResolver(c, &d, conflictText, ConflictResolverOptions{Disabled: true})
|
||||
}, 800, 900)
|
||||
_ = tt.Click("Accept current")
|
||||
if done, _ := d.Resolved(); done != 0 {
|
||||
t.Fatal("disabled resolver accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConflictResolverSnapshot(t *testing.T) {
|
||||
snapshotAfter(t, "conflict_resolver", 640, 560, func(c *ui.Context) {
|
||||
s := ui.Local(c.Root(), "s", func() *ConflictResolverState { return &ConflictResolverState{} })
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
ConflictResolver(c, *s, conflictText, ConflictResolverOptions{})
|
||||
})
|
||||
}, func(tt *ui.Tester) { _ = tt.Click("Accept incoming") })
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// DiffStatOptions configure a DiffStat.
|
||||
type DiffStatOptions struct {
|
||||
// Blocks is the ratio bar length; zero means 5.
|
||||
Blocks int
|
||||
}
|
||||
|
||||
// DiffStat shows added and deleted counts with a ratio bar.
|
||||
func DiffStat(c *ui.Context, added, deleted int, opts DiffStatOptions) *ui.Element {
|
||||
if added < 0 || deleted < 0 {
|
||||
panic("mujica: DiffStat counts must not be negative")
|
||||
}
|
||||
n := opts.Blocks
|
||||
if n == 0 {
|
||||
n = 5
|
||||
}
|
||||
if n < 0 {
|
||||
panic("mujica: DiffStat Blocks is negative")
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
plus, minus := diffStatBlocks(added, deleted, n)
|
||||
row := ui.Row(c).AlignItems(ui.Center).Gap(u * 2).Shrink(0).
|
||||
Label(fmt.Sprintf(core.Msg(c, "diff_stat.label"), added, deleted))
|
||||
row.Children(func() {
|
||||
mono := func(s string, col ui.Color) {
|
||||
ui.Text(c, s).Font(theme.MonoFont).FontFeatures("tnum").FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(col).SingleLine()
|
||||
}
|
||||
mono(fmt.Sprintf("+%d", added), k.Success)
|
||||
mono(fmt.Sprintf("−%d", deleted), k.Danger)
|
||||
ui.Row(c).Gap(2).Shrink(0).Role(ui.RoleNone).Children(func() {
|
||||
for i := range n {
|
||||
col := k.Border
|
||||
switch {
|
||||
case i < plus:
|
||||
col = k.Success
|
||||
case i < plus+minus:
|
||||
col = k.Danger
|
||||
}
|
||||
ui.Box(c).Size(8, 8).Radius(1).Background(col)
|
||||
}
|
||||
})
|
||||
})
|
||||
return row
|
||||
}
|
||||
|
||||
// diffStatBlocks splits n blocks, one at least per nonzero side.
|
||||
func diffStatBlocks(added, deleted, n int) (plus, minus int) {
|
||||
total := added + deleted
|
||||
if total == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
plus = int(math.Round(float64(added) / float64(total) * float64(n)))
|
||||
minus = n - plus
|
||||
if added > 0 && plus == 0 {
|
||||
plus, minus = 1, n-1
|
||||
}
|
||||
if deleted > 0 && minus == 0 && n > 1 {
|
||||
plus, minus = n-1, 1
|
||||
}
|
||||
return plus, minus
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestDiffStatBlocks(t *testing.T) {
|
||||
for _, tc := range []struct{ a, d, n, plus, minus int }{
|
||||
{0, 0, 5, 0, 0},
|
||||
{10, 0, 5, 5, 0},
|
||||
{0, 3, 5, 0, 5},
|
||||
{6, 4, 5, 3, 2},
|
||||
{1000, 1, 5, 4, 1},
|
||||
{1, 1000, 5, 1, 4},
|
||||
{1, 1, 1, 1, 0},
|
||||
} {
|
||||
p, m := diffStatBlocks(tc.a, tc.d, tc.n)
|
||||
if p != tc.plus || m != tc.minus {
|
||||
t.Errorf("blocks(%d,%d,%d) = %d,%d want %d,%d", tc.a, tc.d, tc.n, p, m, tc.plus, tc.minus)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffStatTextAndName(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
DiffStat(c, 12, 3, DiffStatOptions{})
|
||||
}, 300, 60)
|
||||
if !tt.HasText("+12") || !tt.HasText("−3") {
|
||||
t.Fatalf("counts: %q", tt.Texts())
|
||||
}
|
||||
if _, ok := tt.Find("12 additions, 3 deletions"); !ok {
|
||||
t.Fatal("not named for assistive technology")
|
||||
}
|
||||
for _, tc := range []struct{ a, d, n int }{{-1, 0, 0}, {0, -1, 0}, {1, 1, -2}} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%v did not panic", tc)
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
DiffStat(c, tc.a, tc.d, DiffStatOptions{Blocks: tc.n})
|
||||
}, 100, 50, 1)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffStatSnapshot(t *testing.T) {
|
||||
snapshot(t, "diff_stat", 360, 120, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Gap(10).Children(func() {
|
||||
DiffStat(c, 128, 32, DiffStatOptions{})
|
||||
DiffStat(c, 4, 0, DiffStatOptions{})
|
||||
DiffStat(c, 0, 0, DiffStatOptions{Blocks: 8})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// DiffViewerState keeps a DiffViewer's diff, opened folds and scroll place.
|
||||
type DiffViewerState struct {
|
||||
list ui.ListState
|
||||
from, to string
|
||||
diff []internal.DiffLine
|
||||
rows []diffViewerRow
|
||||
expanded map[int]bool
|
||||
split bool
|
||||
context int
|
||||
built bool
|
||||
dirty bool
|
||||
}
|
||||
|
||||
// DiffViewerOptions configure a DiffViewer.
|
||||
type DiffViewerOptions struct {
|
||||
// SideBySide shows old and new in two columns.
|
||||
SideBySide bool
|
||||
// Context is unchanged lines kept around changes; zero means 3.
|
||||
Context int
|
||||
// Language picks syntax colors, as "go"; empty is plain.
|
||||
Language string
|
||||
}
|
||||
|
||||
// diffViewerRow is a fold or one line per side.
|
||||
type diffViewerRow struct {
|
||||
left, right int
|
||||
fold, hidden int
|
||||
}
|
||||
|
||||
// DiffViewer compares two texts by line; give it a height.
|
||||
func DiffViewer(c *ui.Context, s *DiffViewerState, from, to string, opts DiffViewerOptions) *ui.Element {
|
||||
ctx := opts.Context
|
||||
if ctx == 0 {
|
||||
ctx = 3
|
||||
}
|
||||
if ctx < 0 {
|
||||
panic("mujica: DiffViewer Context is negative")
|
||||
}
|
||||
if !s.built || s.from != from || s.to != to {
|
||||
s.diff = internal.LineDiff(internal.SplitLines(from), internal.SplitLines(to))
|
||||
s.from, s.to, s.built, s.dirty, s.expanded = from, to, true, true, map[int]bool{}
|
||||
add, del := internal.DiffStats(s.diff)
|
||||
slog.Debug("mujica: diff viewer diff", "lines", len(s.diff), "added", add, "deleted", del)
|
||||
}
|
||||
if s.dirty || s.split != opts.SideBySide || s.context != ctx {
|
||||
s.rows = diffViewerRows(s.diff, ctx, s.expanded, opts.SideBySide)
|
||||
s.split, s.context, s.dirty = opts.SideBySide, ctx, false
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
digits := float32(len(strconv.Itoa(max(len(s.diff), 1))))
|
||||
numW := digits*8 + core.Density(c).Unit()*3
|
||||
e := ui.List(c, &s.list, len(s.rows), func(i int) {
|
||||
r := s.rows[i]
|
||||
if r.hidden > 0 {
|
||||
diffViewerFold(c, s, r)
|
||||
return
|
||||
}
|
||||
row := ui.Row(c).AlignItems(ui.Stretch).MinHeight(theme.MinHitSize)
|
||||
row.Children(func() {
|
||||
if !opts.SideBySide {
|
||||
diffViewerCell(c, s.diff[r.left], opts.Language, numW, true)
|
||||
return
|
||||
}
|
||||
for _, side := range []int{r.left, r.right} {
|
||||
half := ui.Row(c).Grow(1).Basis(0).MinWidth(0).AlignItems(ui.Stretch).Clip()
|
||||
half.Children(func() {
|
||||
if side < 0 {
|
||||
half.Background(k.Background)
|
||||
return
|
||||
}
|
||||
diffViewerCell(c, s.diff[side], opts.Language, numW, false)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
e.Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius).Font(theme.MonoFont)
|
||||
return e
|
||||
}
|
||||
|
||||
// diffViewerCell shows one diff line with numbers, sign and tint.
|
||||
func diffViewerCell(c *ui.Context, d internal.DiffLine, language string, numW float32, both bool) {
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
small := core.FontSize(c, theme.CaptionSize)
|
||||
sign, col, name := " ", k.TextMuted, ""
|
||||
switch d.Op {
|
||||
case internal.DiffDelete:
|
||||
sign, col, name = "−", k.Danger, core.Msg(c, "diff_viewer.deleted")
|
||||
case internal.DiffInsert:
|
||||
sign, col, name = "+", k.Success, core.Msg(c, "diff_viewer.added")
|
||||
}
|
||||
cell := ui.Row(c).Grow(1).MinWidth(0).AlignItems(ui.Center)
|
||||
if d.Op != internal.DiffEqual {
|
||||
cell.Background(col.Mix(k.Surface, 0.88))
|
||||
}
|
||||
num := func(n int) {
|
||||
t := ""
|
||||
if n > 0 {
|
||||
t = strconv.Itoa(n)
|
||||
}
|
||||
ui.Text(c, t).FontSize(small).FontFeatures("tnum").TextColor(k.TextMuted).Width(numW).TextAlign(ui.End).Shrink(0).Padding(0, u, 0, 0)
|
||||
}
|
||||
cell.Children(func() {
|
||||
if both || d.Op != internal.DiffInsert {
|
||||
num(d.Old)
|
||||
}
|
||||
if both || d.Op == internal.DiffInsert {
|
||||
num(d.New)
|
||||
}
|
||||
ui.Text(c, sign).FontWeight(700).TextColor(col).Width(u * 4).TextAlign(ui.Center).Shrink(0)
|
||||
spans, _ := internal.CodeSpans(k, language, d.Text, false)
|
||||
if len(spans) == 0 || d.Text == "" {
|
||||
spans = []ui.Span{{Text: " "}}
|
||||
}
|
||||
t := ui.RichText(c, spans...).NoWrap().MinWidth(0).Padding(u*0.5, u*2, u*0.5, 0)
|
||||
if name != "" {
|
||||
t.Label(name + ": " + d.Text)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// diffViewerFold is the button standing for hidden unchanged lines.
|
||||
func diffViewerFold(c *ui.Context, s *DiffViewerState, r diffViewerRow) {
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
b := ui.ButtonBase(c).Justify(ui.Start).Gap(u*2).MinHeight(theme.MinHitSize).Padding(0, u*3).Background(k.Background).
|
||||
TextColor(k.TextMuted).Cursor(ui.CursorPointer)
|
||||
label := fmt.Sprintf(core.Msg(c, "diff_viewer.fold"), r.hidden)
|
||||
b.Label(label)
|
||||
internal.Ring(b, k.Focus, 0)
|
||||
if b.Hovered() {
|
||||
b.Background(k.SurfaceHover).TextColor(k.Text)
|
||||
}
|
||||
b.Transition(core.ColorFade)
|
||||
b.Children(func() {
|
||||
ui.Text(c, "⋯").FontWeight(700)
|
||||
ui.Text(c, label).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.CaptionSize))
|
||||
})
|
||||
if b.Clicked() && !b.IsDisabled() {
|
||||
s.expanded[r.fold] = true
|
||||
s.dirty = true
|
||||
slog.Debug("mujica: diff viewer unfold", "at", r.fold, "lines", r.hidden)
|
||||
}
|
||||
}
|
||||
|
||||
// diffViewerRows folds long unchanged runs and pairs split sides.
|
||||
func diffViewerRows(diff []internal.DiffLine, ctx int, expanded map[int]bool, split bool) []diffViewerRow {
|
||||
var rows []diffViewerRow
|
||||
var dels, ins []int
|
||||
flush := func() {
|
||||
for j := 0; j < max(len(dels), len(ins)); j++ {
|
||||
r := diffViewerRow{left: -1, right: -1}
|
||||
if j < len(dels) {
|
||||
r.left = dels[j]
|
||||
}
|
||||
if j < len(ins) {
|
||||
r.right = ins[j]
|
||||
}
|
||||
rows = append(rows, r)
|
||||
}
|
||||
dels, ins = dels[:0], ins[:0]
|
||||
}
|
||||
line := func(i int) {
|
||||
switch {
|
||||
case !split:
|
||||
rows = append(rows, diffViewerRow{left: i, right: -1})
|
||||
case diff[i].Op == internal.DiffDelete:
|
||||
dels = append(dels, i)
|
||||
case diff[i].Op == internal.DiffInsert:
|
||||
ins = append(ins, i)
|
||||
default:
|
||||
flush()
|
||||
rows = append(rows, diffViewerRow{left: i, right: i})
|
||||
}
|
||||
}
|
||||
for i := 0; i < len(diff); {
|
||||
if diff[i].Op != internal.DiffEqual {
|
||||
line(i)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
end := i
|
||||
for end < len(diff) && diff[end].Op == internal.DiffEqual {
|
||||
end++
|
||||
}
|
||||
keepHead, keepTail := ctx, ctx
|
||||
if i == 0 {
|
||||
keepHead = 0
|
||||
}
|
||||
if end == len(diff) {
|
||||
keepTail = 0
|
||||
}
|
||||
hidden := end - i - keepHead - keepTail
|
||||
if hidden <= 1 || expanded[i+keepHead] {
|
||||
for j := i; j < end; j++ {
|
||||
line(j)
|
||||
}
|
||||
} else {
|
||||
for j := i; j < i+keepHead; j++ {
|
||||
line(j)
|
||||
}
|
||||
flush()
|
||||
rows = append(rows, diffViewerRow{left: -1, right: -1, fold: i + keepHead, hidden: hidden})
|
||||
for j := end - keepTail; j < end; j++ {
|
||||
line(j)
|
||||
}
|
||||
}
|
||||
i = end
|
||||
}
|
||||
flush()
|
||||
return rows
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
)
|
||||
|
||||
const diffOld = "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"hi\")\n}\n"
|
||||
const diffNew = "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tname := \"court\"\n\tfmt.Println(\"hi\", name)\n}\n"
|
||||
|
||||
func TestDiffViewerRowsFoldAndPair(t *testing.T) {
|
||||
var a, b []string
|
||||
for i := range 30 {
|
||||
a = append(a, fmt.Sprintf("line %d", i))
|
||||
}
|
||||
b = append(b, a...)
|
||||
b[15] = "changed"
|
||||
d := internal.LineDiff(a, b)
|
||||
rows := diffViewerRows(d, 3, map[int]bool{}, false)
|
||||
// 12 hidden, 3 context, delete, insert, 3 context, 11 hidden.
|
||||
if len(rows) != 10 || rows[0].hidden != 12 || rows[9].hidden != 11 {
|
||||
t.Fatalf("unified rows %+v", rows)
|
||||
}
|
||||
split := diffViewerRows(d, 3, map[int]bool{}, true)
|
||||
if len(split) != 9 || split[4].left < 0 || split[4].right < 0 || d[split[4].left].Op != internal.DiffDelete {
|
||||
t.Fatalf("split rows %+v", split)
|
||||
}
|
||||
open := diffViewerRows(d, 3, map[int]bool{0: true}, false)
|
||||
if len(open) != 21 || open[0].hidden != 0 {
|
||||
t.Fatalf("expanded rows %d", len(open))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerUnfoldAndModes(t *testing.T) {
|
||||
var s DiffViewerState
|
||||
from := strings.Repeat("same\n", 20) + "old\n"
|
||||
to := strings.Repeat("same\n", 20) + "new\n"
|
||||
split := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
DiffViewer(c, &s, from, to, DiffViewerOptions{SideBySide: split}).Height(760)
|
||||
}, 700, 800)
|
||||
if !tt.HasText("17 unchanged lines") {
|
||||
t.Fatalf("fold: %q", tt.Texts())
|
||||
}
|
||||
if _, ok := tt.Find("Deleted: old"); !ok {
|
||||
t.Fatal("deleted line not named")
|
||||
}
|
||||
if _, ok := tt.Find("Added: new"); !ok {
|
||||
t.Fatal("added line not named")
|
||||
}
|
||||
if err := tt.Click("17 unchanged lines"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tt.HasText("17 unchanged lines") || !tt.HasText("17") {
|
||||
t.Fatalf("unfold: %q", tt.Texts())
|
||||
}
|
||||
split = true
|
||||
tt.Frame()
|
||||
if !tt.HasText("−") || !tt.HasText("+") {
|
||||
t.Fatalf("split: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerVirtualized(t *testing.T) {
|
||||
var a, b strings.Builder
|
||||
for i := range 100000 {
|
||||
fmt.Fprintf(&a, "line %d\n", i)
|
||||
if i%1000 == 0 {
|
||||
fmt.Fprintf(&b, "edit %d\n", i)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "line %d\n", i)
|
||||
}
|
||||
}
|
||||
from, to := a.String(), b.String()
|
||||
var s DiffViewerState
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
DiffViewer(c, &s, from, to, DiffViewerOptions{}).Height(400)
|
||||
}, 700, 440)
|
||||
if n := len(tt.Texts()); n > 400 {
|
||||
t.Fatalf("%d texts for 100,000 lines", n)
|
||||
}
|
||||
rows := &s.rows[0]
|
||||
tt.Frame()
|
||||
if &s.rows[0] != rows {
|
||||
t.Fatal("rows rebuilt without a change")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerSnapshot(t *testing.T) {
|
||||
snapshot(t, "diff_viewer", 640, 520, func(c *ui.Context) {
|
||||
s := ui.Local(c.Root(), "u", func() *DiffViewerState { return &DiffViewerState{} })
|
||||
p := ui.Local(c.Root(), "p", func() *DiffViewerState { return &DiffViewerState{} })
|
||||
ui.Column(c).Padding(16).Gap(12).Children(func() {
|
||||
DiffViewer(c, *s, diffOld, diffNew, DiffViewerOptions{Language: "go", Context: 1}).Height(220)
|
||||
DiffViewer(c, *p, diffOld, diffNew, DiffViewerOptions{Language: "go", SideBySide: true, Context: 1}).Height(220)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package git holds MujicaUI version control views: diffs, commits, branches and merges.
|
||||
package git
|
||||
@@ -0,0 +1,266 @@
|
||||
package git_test
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/agent"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/git"
|
||||
)
|
||||
|
||||
func ExampleDiffViewer() {
|
||||
var state git.DiffViewerState
|
||||
from := "func main() {\n\tprintln(\"hi\")\n}\n"
|
||||
to := "func main() {\n\tprintln(\"hello\")\n}\n"
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
git.DiffViewer(c, &state, from, to, git.DiffViewerOptions{Language: "go", SideBySide: true}).Height(240)
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleInlineDiff() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
git.InlineDiff(c, "The court meets at noon", "The court meets at dusk", git.InlineDiffOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleThreeWayMerge() {
|
||||
var state git.ThreeWayMergeState
|
||||
base, local, remote := "a\nb\nc", "a\nB1\nc", "a\nB2\nc"
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := git.ThreeWayMerge(c, &state, base, local, remote, git.ThreeWayMergeOptions{})
|
||||
r.Element.Height(280)
|
||||
if done, total := state.Resolved(); r.Changed() && done == total {
|
||||
_ = state.Result() // write the merged file
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleConflictResolver() {
|
||||
var state git.ConflictResolverState
|
||||
text := "a\n<<<<<<< HEAD\nmine\n=======\ntheirs\n>>>>>>> feature\nb"
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if git.ConflictResolver(c, &state, text, git.ConflictResolverOptions{}).Changed() {
|
||||
_ = state.Result() // save the file once every block is resolved
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleChangesList() {
|
||||
var state git.ChangesListState
|
||||
files := []git.ChangedFile{
|
||||
{Path: "main.go", Status: git.GitModified, Staged: true},
|
||||
{Path: "notes.txt", Status: git.GitUntracked},
|
||||
}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := git.ChangesList(c, &state, files, git.ChangesListOptions{})
|
||||
r.Element.Height(240)
|
||||
if action, path, ok := r.Action(); ok {
|
||||
_, _ = action, path // run git add or git restore --staged
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleCommitInput() {
|
||||
msg := git.CommitMessage{}
|
||||
busy := false
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := git.CommitInput(c, &msg, git.CommitInputOptions{Busy: busy})
|
||||
if r.Committed() || r.Amended() {
|
||||
busy = true // run git commit, then clear busy
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleCommitGraph() {
|
||||
var state git.CommitGraphState
|
||||
commits := []git.Commit{
|
||||
{Hash: "c3", Parents: []string{"c2", "b1"}, Subject: "Merge branch", Refs: []string{"main"}},
|
||||
{Hash: "b1", Parents: []string{"c1"}, Subject: "Feature"},
|
||||
{Hash: "c2", Parents: []string{"c1"}, Subject: "Fix"},
|
||||
{Hash: "c1", Subject: "Initial commit"},
|
||||
}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
git.CommitGraph(c, &state, commits, git.CommitGraphOptions{}).Element.Height(240)
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleCommitList() {
|
||||
var state data.ListState[string]
|
||||
commits := []git.Commit{{Hash: "a1b2c3d4", Author: "Sakiko", When: time.Now(), Subject: "Add the commit list"}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := git.CommitList(c, &state, commits, git.CommitListOptions{})
|
||||
r.Element.Height(240)
|
||||
if hash, ok := state.Selected(); ok && r.Submitted() {
|
||||
_ = hash // show the commit
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleBranchSelector() {
|
||||
branch := "main"
|
||||
branches := []git.Branch{{Name: "main", Current: true}, {Name: "origin/main", Remote: true}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := git.BranchSelector(c, &branch, branches, git.BranchSelectorOptions{AllowCreate: true})
|
||||
if name, ok := r.Created(); ok {
|
||||
_ = name // run git switch -c name
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleBranchList() {
|
||||
var state data.ListState[string]
|
||||
branches := []git.Branch{{Name: "main", Current: true, Upstream: "origin/main", Ahead: 2}}
|
||||
actions := []git.GitAction{{ID: "delete", Label: "Delete", Icon: "trash-2"}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := git.BranchList(c, &state, branches, git.BranchListOptions{Actions: actions})
|
||||
r.Element.Height(200)
|
||||
if action, name, ok := r.Action(); ok {
|
||||
_, _ = action, name // run the action on the branch
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleTagList() {
|
||||
var state data.ListState[string]
|
||||
tags := []git.GitTag{{Name: "v1.1.0", Target: "a1b2c3d4", Annotation: "Release"}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := git.TagList(c, &state, tags, git.TagListOptions{})
|
||||
r.Element.Height(200)
|
||||
if _, hash, ok := r.Action(); ok {
|
||||
_ = hash // show the tagged commit
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleStashList() {
|
||||
var state data.ListState[string]
|
||||
stashes := []git.Stash{{Ref: "stash@{0}", Message: "WIP on main", Branch: "main"}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := git.StashList(c, &state, stashes, git.StashListOptions{})
|
||||
r.Element.Height(200)
|
||||
if action, ref, ok := r.Action(); ok {
|
||||
_, _ = action, ref // run git stash apply, pop or drop
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleBlameView() {
|
||||
var state git.BlameViewState
|
||||
lines := []git.BlameLine{
|
||||
{Hash: "a1b2c3d4", Author: "Sakiko", When: time.Now(), Summary: "Add main", Text: "package main"},
|
||||
{Hash: "a1b2c3d4", Author: "Sakiko", When: time.Now(), Summary: "Add main", Text: "func main() {}"},
|
||||
}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
git.BlameView(c, &state, lines, git.BlameViewOptions{Language: "go"}).Height(240)
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleFileHistory() {
|
||||
var state data.ListState[string]
|
||||
commits := []git.Commit{{Hash: "a1b2c3d4", Author: "Sakiko", When: time.Now(), Subject: "Edit main.go"}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := git.FileHistory(c, &state, "main.go", commits, git.FileHistoryOptions{})
|
||||
r.Element.Height(240)
|
||||
if _, hash, ok := r.Action(); ok {
|
||||
_ = hash // open the diff of main.go at hash
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExamplePullRequestCard() {
|
||||
pr := git.PullRequest{Number: 42, Title: "Add git components", Author: "Sakiko", Head: "feature", Base: "main",
|
||||
ChecksPassed: 3, Reviewers: []git.Reviewer{{Name: "Uika", State: git.ReviewApproved}}, Added: 120, Deleted: 8, Files: 6}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if git.PullRequestCard(c, pr, git.PullRequestCardOptions{}).Changed() {
|
||||
// open the pull request
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleReviewComment() {
|
||||
thread := git.ReviewThread{File: "main.go", Line: 12, Messages: []git.ReviewMessage{
|
||||
{Author: "Uika", When: time.Now(), Body: "Can this return early?"},
|
||||
}}
|
||||
draft := ""
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := git.ReviewComment(c, thread, &draft, git.ReviewCommentOptions{})
|
||||
if text, ok := r.Replied(); ok {
|
||||
thread.Messages = append(thread.Messages, git.ReviewMessage{Author: "Sakiko", When: time.Now(), Body: text})
|
||||
}
|
||||
if r.Resolved() || r.Reopened() {
|
||||
thread.Resolved = !thread.Resolved
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleDiffStat() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
git.DiffStat(c, 120, 8, git.DiffStatOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleGitStatusBadge() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
git.GitStatusBadge(c, git.GitModified, git.GitStatusBadgeOptions{ShowLabel: true})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleCommandBlock() {
|
||||
open := true
|
||||
run := agent.CommandRun{Command: "go test ./...", Output: "\x1b[32mok\x1b[0m example 0.4s", Duration: 400 * time.Millisecond}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := git.CommandBlock(c, run, &open, git.CommandBlockOptions{})
|
||||
if what, ok := r.Copied(); ok {
|
||||
_ = what // "command" or "output" is on the clipboard
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleAnsiText() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
git.AnsiText(c, "\x1b[1;32mPASS\x1b[0m 12 tests\n\x1b[31mFAIL\x1b[0m 1 test", git.AnsiTextOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// FileHistoryOptions configure a FileHistory.
|
||||
type FileHistoryOptions struct {
|
||||
// TimeFormat lays out commit times; empty means "2006-01-02 15:04".
|
||||
TimeFormat string
|
||||
Status data.DataStatus
|
||||
Error string
|
||||
}
|
||||
|
||||
// FileHistory lists commits that changed path; Action "diff" opens one.
|
||||
func FileHistory(c *ui.Context, s *data.ListState[string], path string, commits []Commit, opts FileHistoryOptions) GitListResult {
|
||||
if path == "" {
|
||||
panic("mujica: FileHistory needs a path")
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
layout := gitTimeLayout(opts.TimeFormat)
|
||||
var r GitListResult
|
||||
col := ui.Column(c).AlignItems(ui.Stretch).Gap(u * 2)
|
||||
col.Children(func() {
|
||||
ui.Row(c).AlignItems(ui.Center).Gap(u * 2).Children(func() {
|
||||
ui.Icon(c, icons.Must("history")).Size(16, 16).TextColor(k.Ornament.Mix(k.Text, 0.35))
|
||||
ui.Text(c, path).Font(theme.MonoFont).FontWeight(600).SingleLine().MinWidth(0)
|
||||
ui.Text(c, core.Msg(c, "file_history.title")).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Shrink(0)
|
||||
})
|
||||
actions := []GitAction{{ID: "diff", Label: core.Msg(c, "file_history.diff"), Icon: "arrow-left-right"}}
|
||||
v := data.List(c, s, len(commits), data.ListOptions[string]{
|
||||
Key: func(i int) string { return gitCommitHash(commits[i]) },
|
||||
Label: func(i int) string { return commits[i].Subject },
|
||||
Status: opts.Status, Error: opts.Error, Empty: core.Msg(c, "file_history.empty"),
|
||||
}, func(i int) {
|
||||
commitListRow(c, commits[i], layout)
|
||||
gitRowActions(c, actions, commits[i].Hash, &r)
|
||||
})
|
||||
v.Element.Grow(1)
|
||||
r.changed, r.submitted = v.Changed(), v.Submitted()
|
||||
})
|
||||
r.Element = col
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
)
|
||||
|
||||
func TestFileHistoryDiffAction(t *testing.T) {
|
||||
var s data.ListState[string]
|
||||
commits := gitHistory(3)
|
||||
diff := ""
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := FileHistory(c, &s, "commit_graph.go", commits, FileHistoryOptions{})
|
||||
r.Element.Height(240)
|
||||
if a, item, ok := r.Action(); ok && a == "diff" {
|
||||
diff = item
|
||||
}
|
||||
}, 700, 280)
|
||||
if !tt.HasText("commit_graph.go") || !tt.HasText("History") {
|
||||
t.Fatalf("header: %q", tt.Texts())
|
||||
}
|
||||
r, _ := tt.Find("Commit number 2")
|
||||
tt.ClickAt(700-30, r.Y+r.H/2)
|
||||
if diff != commits[1].Hash {
|
||||
t.Fatalf("diff %q", diff)
|
||||
}
|
||||
_ = tt.Click("Commit number 1")
|
||||
tt.Key(0, ui.KeyUp)
|
||||
if key, _ := s.Selected(); key != commits[1].Hash {
|
||||
t.Fatalf("selected %q", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileHistoryEmptyAndInvalid(t *testing.T) {
|
||||
var s data.ListState[string]
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
FileHistory(c, &s, "a.go", nil, FileHistoryOptions{}).Element.Height(200)
|
||||
}, 400, 240)
|
||||
if !tt.HasText("No history for this file") {
|
||||
t.Fatalf("empty: %q", tt.Texts())
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
var s data.ListState[string]
|
||||
FileHistory(c, &s, "", nil, FileHistoryOptions{})
|
||||
}, 300, 120, 1)
|
||||
}
|
||||
|
||||
func TestFileHistorySnapshot(t *testing.T) {
|
||||
commits := gitHistory(3)
|
||||
snapshot(t, "file_history", 640, 200, func(c *ui.Context) {
|
||||
s := ui.Local(c.Root(), "list", func() *data.ListState[string] { return &data.ListState[string]{} })
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
FileHistory(c, *s, "internal/git.go", commits, FileHistoryOptions{}).Element.Height(170)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// GitStatus is a file's change state in version control.
|
||||
type GitStatus int
|
||||
|
||||
const (
|
||||
GitModified GitStatus = iota
|
||||
GitAdded
|
||||
GitDeleted
|
||||
GitUntracked
|
||||
GitRenamed
|
||||
GitCopied
|
||||
)
|
||||
|
||||
var gitStatusKeys = [...]string{"modified", "added", "deleted", "untracked", "renamed", "copied"}
|
||||
|
||||
// Letter is the git status code, as M.
|
||||
func (s GitStatus) Letter() string { return string("MADURC"[gitStatusIndex(s)]) }
|
||||
|
||||
func gitStatusIndex(s GitStatus) int {
|
||||
if s < GitModified || s > GitCopied {
|
||||
panic("mujica: unknown GitStatus")
|
||||
}
|
||||
return int(s)
|
||||
}
|
||||
|
||||
// GitStatusBadgeOptions configure a GitStatusBadge.
|
||||
type GitStatusBadgeOptions struct {
|
||||
// ShowLabel writes the status name beside the letter.
|
||||
ShowLabel bool
|
||||
}
|
||||
|
||||
// GitStatusBadge marks a status by letter, outline and color.
|
||||
func GitStatusBadge(c *ui.Context, status GitStatus, opts GitStatusBadgeOptions) *ui.Element {
|
||||
k := core.Tokens(c)
|
||||
name := core.Msg(c, "git_status_badge."+gitStatusKeys[gitStatusIndex(status)])
|
||||
col := gitStatusColor(k, status)
|
||||
row := ui.Row(c).AlignItems(ui.Center).Gap(core.Density(c).Unit() * 1.5).Shrink(0).Label(name).Role(ui.RoleImage)
|
||||
if !opts.ShowLabel {
|
||||
row.Tooltip(name)
|
||||
}
|
||||
row.Children(func() {
|
||||
ui.Box(c).Size(18, 18).Shrink(0).Center().Radius(theme.ControlRadius).Border(theme.BorderWidth, col).
|
||||
Children(func() {
|
||||
ui.Text(c, status.Letter()).Font(theme.MonoFont).FontWeight(700).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(col)
|
||||
})
|
||||
if opts.ShowLabel {
|
||||
ui.Text(c, name).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
|
||||
}
|
||||
})
|
||||
return row
|
||||
}
|
||||
|
||||
func gitStatusColor(k theme.Tokens, s GitStatus) ui.Color {
|
||||
switch s {
|
||||
case GitAdded:
|
||||
return k.Success
|
||||
case GitDeleted:
|
||||
return k.Danger
|
||||
case GitUntracked, GitRenamed:
|
||||
return k.Info
|
||||
case GitCopied:
|
||||
return k.AccentText
|
||||
}
|
||||
return k.Warning
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestGitStatusBadgeLettersAndNames(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Row(c).Gap(8).Children(func() {
|
||||
for s := GitModified; s <= GitCopied; s++ {
|
||||
GitStatusBadge(c, s, GitStatusBadgeOptions{})
|
||||
}
|
||||
})
|
||||
}, 400, 80)
|
||||
for i, name := range []string{"Modified", "Added", "Deleted", "Untracked", "Renamed", "Copied"} {
|
||||
if _, ok := tt.Find(name); !ok {
|
||||
t.Errorf("no badge named %q", name)
|
||||
}
|
||||
if got := GitStatus(i).Letter(); got != string("MADURC"[i]) {
|
||||
t.Errorf("letter %d = %q", i, got)
|
||||
}
|
||||
}
|
||||
if !tt.HasText("R") || !tt.HasText("U") {
|
||||
t.Fatalf("letters not shown: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitStatusBadgeLabelAndInvalid(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{Locale: core.ZhCN})
|
||||
GitStatusBadge(c, GitDeleted, GitStatusBadgeOptions{ShowLabel: true})
|
||||
}, 300, 80)
|
||||
if !tt.HasText("已删除") {
|
||||
t.Fatalf("label missing: %q", tt.Texts())
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("unknown status did not panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
GitStatusBadge(c, GitStatus(9), GitStatusBadgeOptions{})
|
||||
}, 100, 50, 1)
|
||||
}
|
||||
|
||||
func TestGitStatusBadgeSnapshot(t *testing.T) {
|
||||
snapshot(t, "git_status_badge", 520, 90, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Gap(12).Children(func() {
|
||||
ui.Row(c).Gap(10).Children(func() {
|
||||
for s := GitModified; s <= GitCopied; s++ {
|
||||
GitStatusBadge(c, s, GitStatusBadgeOptions{})
|
||||
}
|
||||
})
|
||||
ui.Row(c).Gap(14).Children(func() {
|
||||
for _, s := range []GitStatus{GitModified, GitAdded, GitDeleted} {
|
||||
GitStatusBadge(c, s, GitStatusBadgeOptions{ShowLabel: true})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal/snaptest"
|
||||
)
|
||||
|
||||
// snapshot compares view in both themes with testdata/snapshots/<os>/<name>-<mode>.png.
|
||||
func snapshot(t *testing.T, name string, w, h int, view func(c *ui.Context)) {
|
||||
t.Helper()
|
||||
snaptest.Run(t, name, w, h, snapshotTheme, view, nil)
|
||||
}
|
||||
|
||||
// snapshotAfter is snapshot with drive sending input before the capture.
|
||||
func snapshotAfter(t *testing.T, name string, w, h int, view func(c *ui.Context), drive func(tt *ui.Tester)) {
|
||||
t.Helper()
|
||||
snaptest.Run(t, name, w, h, snapshotTheme, view, drive)
|
||||
}
|
||||
|
||||
func snapshotTheme(c *ui.Context, mode string) {
|
||||
m := core.Light
|
||||
if mode == "dark" {
|
||||
m = core.Dark
|
||||
}
|
||||
core.Use(c, core.Settings{Mode: m})
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// InlineDiffOptions configure an InlineDiff.
|
||||
type InlineDiffOptions struct {
|
||||
// Mono sets the lines in the code font.
|
||||
Mono bool
|
||||
}
|
||||
|
||||
type inlineDiffCache struct {
|
||||
from, to string
|
||||
a, b []internal.WordSpan
|
||||
done bool
|
||||
}
|
||||
|
||||
// InlineDiff shows old and new lines with changed words marked.
|
||||
func InlineDiff(c *ui.Context, from, to string, opts InlineDiffOptions) *ui.Element {
|
||||
k := core.Tokens(c)
|
||||
col := ui.Column(c).AlignItems(ui.Stretch).Shrink(0).Radius(theme.ControlRadius).Clip()
|
||||
cache := ui.Local(col, "words", func() *inlineDiffCache { return &inlineDiffCache{} })
|
||||
w := *cache
|
||||
if !w.done || w.from != from || w.to != to {
|
||||
w.a, w.b = internal.WordDiff(from, to)
|
||||
w.from, w.to, w.done = from, to, true
|
||||
}
|
||||
if opts.Mono {
|
||||
col.Font(theme.MonoFont)
|
||||
}
|
||||
col.Children(func() {
|
||||
inlineDiffLine(c, "−", core.Msg(c, "inline_diff.removed"), w.a, k.Danger, k)
|
||||
inlineDiffLine(c, "+", core.Msg(c, "inline_diff.added"), w.b, k.Success, k)
|
||||
})
|
||||
return col
|
||||
}
|
||||
|
||||
// inlineDiffLine shows one side with its changed words marked.
|
||||
func inlineDiffLine(c *ui.Context, sign, name string, spans []internal.WordSpan, col ui.Color, k theme.Tokens) {
|
||||
u := core.Density(c).Unit()
|
||||
out := make([]ui.Span, 0, len(spans))
|
||||
plain := ""
|
||||
for _, s := range spans {
|
||||
plain += s.Text
|
||||
sp := ui.Span{Text: s.Text}
|
||||
switch s.Op {
|
||||
case internal.DiffDelete:
|
||||
sp.Background, sp.Strikethrough, sp.DecorationColor = col.Mix(k.Surface, 0.7), true, col
|
||||
case internal.DiffInsert:
|
||||
sp.Background, sp.Underline, sp.DecorationColor = col.Mix(k.Surface, 0.7), true, col
|
||||
}
|
||||
out = append(out, sp)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
out = append(out, ui.Span{Text: " "})
|
||||
}
|
||||
ui.Row(c).AlignItems(ui.Start).Gap(u*2).Padding(u, u*3).Background(col.Mix(k.Surface, 0.9)).Label(name + ": " + plain).
|
||||
Children(func() {
|
||||
ui.Text(c, sign).Font(theme.MonoFont).FontWeight(700).TextColor(col).Shrink(0)
|
||||
ui.RichText(c, out...).TextColor(k.Text).MinWidth(0)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestInlineDiffNamesAndShowsBothLines(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
InlineDiff(c, "The court meets at noon", "The court meets at dusk", InlineDiffOptions{})
|
||||
}, 600, 120)
|
||||
if _, ok := tt.Find("Old: The court meets at noon"); !ok {
|
||||
t.Fatalf("old line: %q", tt.Texts())
|
||||
}
|
||||
if _, ok := tt.Find("New: The court meets at dusk"); !ok {
|
||||
t.Fatalf("new line: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInlineDiffCachesWords(t *testing.T) {
|
||||
from, to := "a b", "a c"
|
||||
var cache *inlineDiffCache
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
e := InlineDiff(c, from, to, InlineDiffOptions{Mono: true})
|
||||
cache = *ui.Local(e, "words", func() *inlineDiffCache { return nil })
|
||||
}, 300, 100)
|
||||
first := &cache.a[0]
|
||||
tt.Frame()
|
||||
if &cache.a[0] != first {
|
||||
t.Fatal("word diff recomputed without a change")
|
||||
}
|
||||
to = "a d"
|
||||
tt.Frame()
|
||||
if cache.b[1].Text != "d" {
|
||||
t.Fatalf("new text not diffed: %+v", cache.b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInlineDiffSnapshot(t *testing.T) {
|
||||
snapshot(t, "inline_diff", 560, 200, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Gap(12).Children(func() {
|
||||
InlineDiff(c, "The court meets at noon in the east hall.", "The council meets at dusk in the east hall.", InlineDiffOptions{})
|
||||
InlineDiff(c, "return a + b", "return a - total", InlineDiffOptions{Mono: true})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/display"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// PullRequestState is where a pull request stands.
|
||||
type PullRequestState int
|
||||
|
||||
const (
|
||||
PullRequestOpen PullRequestState = iota
|
||||
PullRequestDraft
|
||||
PullRequestMerged
|
||||
PullRequestClosed
|
||||
)
|
||||
|
||||
// ReviewState is a reviewer's verdict.
|
||||
type ReviewState int
|
||||
|
||||
const (
|
||||
ReviewPending ReviewState = iota
|
||||
ReviewApproved
|
||||
ReviewChangesRequested
|
||||
)
|
||||
|
||||
// Reviewer is a person asked to review.
|
||||
type Reviewer struct {
|
||||
Name string
|
||||
State ReviewState
|
||||
}
|
||||
|
||||
// PullRequest is what a PullRequestCard shows; the caller fetches it.
|
||||
type PullRequest struct {
|
||||
Number int
|
||||
Title string
|
||||
Author string
|
||||
State PullRequestState
|
||||
Head, Base string
|
||||
// Checks counts CI results.
|
||||
ChecksPassed, ChecksFailed, ChecksPending int
|
||||
Reviewers []Reviewer
|
||||
Added, Deleted, Files int
|
||||
}
|
||||
|
||||
// PullRequestCardOptions configure a PullRequestCard.
|
||||
type PullRequestCardOptions struct {
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// PullRequestCard summarizes a pull request; Changed reports opening.
|
||||
func PullRequestCard(c *ui.Context, pr PullRequest, opts PullRequestCardOptions) core.ChoiceResult {
|
||||
if pr.Number <= 0 || pr.Title == "" {
|
||||
panic("mujica: PullRequest needs a positive Number and a Title")
|
||||
}
|
||||
if pr.ChecksPassed < 0 || pr.ChecksFailed < 0 || pr.ChecksPending < 0 || pr.Files < 0 {
|
||||
panic("mujica: PullRequest counts must not be negative")
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
small := core.FontSize(c, theme.CaptionSize)
|
||||
card := ui.Column(c).AlignItems(ui.Stretch).Gap(u*2).Padding(u*4).Background(k.Surface).
|
||||
Border(theme.BorderWidth, k.Border).Radius(theme.CardRadius).Disabled(opts.Disabled)
|
||||
res := core.ChoiceResult{Element: card}
|
||||
card.Children(func() {
|
||||
ui.Row(c).AlignItems(ui.Center).Gap(u * 2).Children(func() {
|
||||
pullRequestStateBadge(c, pr.State)
|
||||
title := ui.ButtonBase(c).Justify(ui.Start).Gap(u*2).Padding(u, u).Radius(theme.ControlRadius).
|
||||
Role(ui.RoleLink).Label(fmt.Sprintf("#%d %s", pr.Number, pr.Title)).Shrink(1).MinWidth(0).Cursor(ui.CursorPointer)
|
||||
internal.Ring(title, k.Focus, theme.ControlRadius)
|
||||
if title.Hovered() {
|
||||
title.Background(k.SurfaceHover)
|
||||
}
|
||||
title.Transition(core.ColorFade)
|
||||
title.Children(func() {
|
||||
ui.Text(c, pr.Title).FontWeight(600).SingleLine().MinWidth(0)
|
||||
ui.Text(c, fmt.Sprintf("#%d", pr.Number)).TextColor(k.TextMuted).FontFeatures("tnum").Shrink(0)
|
||||
})
|
||||
if title.Clicked() && !title.IsDisabled() {
|
||||
res = core.NewChoiceResult(res.Element, true)
|
||||
slog.Debug("mujica: pull request card open", "number", pr.Number)
|
||||
}
|
||||
})
|
||||
ui.Row(c).AlignItems(ui.Center).Gap(u * 2).FontSize(small).TextColor(k.TextMuted).Wrap().Children(func() {
|
||||
ui.Text(c, pr.Author).FontWeight(600).TextColor(k.Text)
|
||||
if pr.Head != "" && pr.Base != "" {
|
||||
ui.Icon(c, icons.Must("git-pull-request")).Size(12, 12)
|
||||
ui.Text(c, pr.Head+" → "+pr.Base).Font(theme.MonoFont).SingleLine().MaxWidth(320)
|
||||
}
|
||||
})
|
||||
ui.Row(c).AlignItems(ui.Center).Gap(u * 4).Wrap().FontSize(small).Children(func() {
|
||||
pullRequestChecks(c, pr)
|
||||
if len(pr.Reviewers) > 0 {
|
||||
ui.Row(c).AlignItems(ui.Center).Gap(u * 2).Children(func() {
|
||||
for _, r := range pr.Reviewers {
|
||||
pullRequestReviewer(c, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
ui.Spacer(c)
|
||||
ui.Text(c, fmt.Sprintf(core.Msg(c, "pull_request_card.files"), pr.Files)).TextColor(k.TextMuted).FontFeatures("tnum")
|
||||
DiffStat(c, pr.Added, pr.Deleted, DiffStatOptions{})
|
||||
})
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
func pullRequestStateBadge(c *ui.Context, st PullRequestState) {
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
icon, col, key := "git-pull-request", k.Success, "open"
|
||||
switch st {
|
||||
case PullRequestOpen:
|
||||
case PullRequestDraft:
|
||||
icon, col, key = "git-pull-request", k.TextMuted, "draft"
|
||||
case PullRequestMerged:
|
||||
icon, col, key = "git-merge", k.AccentText, "merged"
|
||||
case PullRequestClosed:
|
||||
icon, col, key = "circle-x", k.Danger, "closed"
|
||||
default:
|
||||
panic("mujica: unknown PullRequestState")
|
||||
}
|
||||
ui.Row(c).AlignItems(ui.Center).Gap(u).Padding(2, u*2).Radius(theme.ControlRadius).Border(theme.BorderWidth, col).
|
||||
TextColor(col).Shrink(0).Children(func() {
|
||||
ui.Icon(c, icons.Must(icon)).Size(14, 14)
|
||||
ui.Text(c, core.Msg(c, "pull_request_card."+key)).FontSize(core.FontSize(c, theme.CaptionSize)).FontWeight(600).SingleLine()
|
||||
})
|
||||
}
|
||||
|
||||
func pullRequestChecks(c *ui.Context, pr PullRequest) {
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
ui.Row(c).AlignItems(ui.Center).Gap(u * 3).Children(func() {
|
||||
for _, ch := range []struct {
|
||||
n int
|
||||
icon, key string
|
||||
col ui.Color
|
||||
}{
|
||||
{pr.ChecksPassed, "circle-check", "pull_request_card.passed", k.Success},
|
||||
{pr.ChecksFailed, "circle-x", "pull_request_card.failed", k.Danger},
|
||||
{pr.ChecksPending, "clock", "pull_request_card.pending", k.Warning},
|
||||
} {
|
||||
if ch.n == 0 {
|
||||
continue
|
||||
}
|
||||
name := fmt.Sprintf(core.Msg(c, ch.key), ch.n)
|
||||
ui.Row(c).AlignItems(ui.Center).Gap(u).TextColor(ch.col).Label(name).Tooltip(name).Children(func() {
|
||||
ui.Icon(c, icons.Must(ch.icon)).Size(14, 14)
|
||||
ui.Text(c, fmt.Sprint(ch.n)).FontFeatures("tnum")
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func pullRequestReviewer(c *ui.Context, r Reviewer) {
|
||||
k := core.Tokens(c)
|
||||
key, icon, col := "pull_request_card.review_pending", "clock", k.TextMuted
|
||||
switch r.State {
|
||||
case ReviewPending:
|
||||
case ReviewApproved:
|
||||
key, icon, col = "pull_request_card.approved", "check", k.Success
|
||||
case ReviewChangesRequested:
|
||||
key, icon, col = "pull_request_card.changes", "circle-alert", k.Danger
|
||||
default:
|
||||
panic("mujica: unknown ReviewState")
|
||||
}
|
||||
name := r.Name + ": " + core.Msg(c, key)
|
||||
ui.Box(c).Size(28, 28).Shrink(0).Label(name).Tooltip(name).Children(func() {
|
||||
display.Avatar(c, r.Name, display.AvatarOptions{Size: 24}).Absolute().Left(0).Top(0)
|
||||
ui.Box(c).Size(14, 14).Radius(7).Background(k.Surface).Center().Absolute().Right(0).Bottom(0).TextColor(col).
|
||||
Children(func() { ui.Icon(c, icons.Must(icon)).Size(10, 10) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func pullRequestSample() PullRequest {
|
||||
return PullRequest{Number: 318, Title: "Git components 301–320", Author: "Sakiko", State: PullRequestOpen,
|
||||
Head: "feature/git", Base: "main", ChecksPassed: 12, ChecksFailed: 1, ChecksPending: 2,
|
||||
Reviewers: []Reviewer{{Name: "Uika", State: ReviewApproved}, {Name: "Mutsumi", State: ReviewChangesRequested}, {Name: "Umiri"}},
|
||||
Added: 2412, Deleted: 310, Files: 46}
|
||||
}
|
||||
|
||||
func TestPullRequestCardShowsAndOpens(t *testing.T) {
|
||||
opens := 0
|
||||
disabled := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if PullRequestCard(c, pullRequestSample(), PullRequestCardOptions{Disabled: disabled}).Changed() {
|
||||
opens++
|
||||
}
|
||||
}, 700, 240)
|
||||
for _, name := range []string{"Open", "#318", "feature/git → main", "12 checks passed", "1 checks failed", "2 checks pending",
|
||||
"Uika: approved", "Mutsumi: changes requested", "Umiri: review pending", "46 files", "2412 additions, 310 deletions"} {
|
||||
if _, ok := tt.Find(name); !ok {
|
||||
t.Errorf("missing %q", name)
|
||||
}
|
||||
}
|
||||
if err := tt.Click("Git components 301–320"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if opens != 2 {
|
||||
t.Fatalf("opens %d", opens)
|
||||
}
|
||||
disabled = true
|
||||
tt.Frame()
|
||||
_ = tt.Click("Git components 301–320")
|
||||
if opens != 2 {
|
||||
t.Fatal("disabled card opened")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullRequestCardStatesAndInvalid(t *testing.T) {
|
||||
for st, word := range map[PullRequestState]string{PullRequestDraft: "Draft", PullRequestMerged: "Merged", PullRequestClosed: "Closed"} {
|
||||
pr := pullRequestSample()
|
||||
pr.State = st
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
PullRequestCard(c, pr, PullRequestCardOptions{})
|
||||
}, 700, 240)
|
||||
if !tt.HasText(word) {
|
||||
t.Errorf("state %d: %q", st, tt.Texts())
|
||||
}
|
||||
}
|
||||
for _, pr := range []PullRequest{{Title: "x"}, {Number: 1}, {Number: 1, Title: "x", Files: -1}, {Number: 1, Title: "x", State: 9}} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%+v did not panic", pr)
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
PullRequestCard(c, pr, PullRequestCardOptions{})
|
||||
}, 300, 100, 1)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullRequestCardSnapshot(t *testing.T) {
|
||||
snapshot(t, "pull_request_card", 640, 300, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Gap(12).Children(func() {
|
||||
PullRequestCard(c, pullRequestSample(), PullRequestCardOptions{})
|
||||
pr := pullRequestSample()
|
||||
pr.Number, pr.State, pr.Title, pr.Reviewers, pr.ChecksFailed, pr.ChecksPending = 301, PullRequestMerged, "Diff viewer", nil, 0, 0
|
||||
PullRequestCard(c, pr, PullRequestCardOptions{})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/display"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// ReviewMessage is one comment of a review thread.
|
||||
type ReviewMessage struct {
|
||||
Author string
|
||||
When time.Time
|
||||
Body string
|
||||
}
|
||||
|
||||
// ReviewThread is a line-level review discussion.
|
||||
type ReviewThread struct {
|
||||
File string
|
||||
Line int
|
||||
Resolved bool
|
||||
Messages []ReviewMessage
|
||||
}
|
||||
|
||||
// ReviewCommentOptions configure a ReviewComment.
|
||||
type ReviewCommentOptions struct {
|
||||
// TimeFormat lays out message times; empty means "2006-01-02 15:04".
|
||||
TimeFormat string
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// ReviewCommentResult is a built ReviewComment and its events.
|
||||
type ReviewCommentResult struct {
|
||||
Element *ui.Element
|
||||
reply string
|
||||
resolved, reopened bool
|
||||
}
|
||||
|
||||
// Replied returns the reply sent this frame.
|
||||
func (r ReviewCommentResult) Replied() (string, bool) { return r.reply, r.reply != "" }
|
||||
|
||||
// Resolved reports the Resolve button this frame.
|
||||
func (r ReviewCommentResult) Resolved() bool { return r.resolved }
|
||||
|
||||
// Reopened reports the Reopen button this frame.
|
||||
func (r ReviewCommentResult) Reopened() bool { return r.reopened }
|
||||
|
||||
// ReviewComment shows a line's review thread; *draft is the reply.
|
||||
func ReviewComment(c *ui.Context, thread ReviewThread, draft *string, opts ReviewCommentOptions) ReviewCommentResult {
|
||||
if thread.File == "" || thread.Line <= 0 || len(thread.Messages) == 0 {
|
||||
panic("mujica: ReviewThread needs a File, a positive Line and a message")
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
small := core.FontSize(c, theme.CaptionSize)
|
||||
layout := gitTimeLayout(opts.TimeFormat)
|
||||
card := ui.Column(c).AlignItems(ui.Stretch).Background(k.Surface).Border(theme.BorderWidth, k.Border).
|
||||
Radius(theme.CardRadius).Disabled(opts.Disabled).Clip()
|
||||
res := ReviewCommentResult{Element: card}
|
||||
card.Children(func() {
|
||||
ui.Row(c).AlignItems(ui.Center).Gap(u*2).Padding(u*2, u*3).Background(k.Background).FontSize(small).Children(func() {
|
||||
ui.Icon(c, icons.Must("message-square")).Size(14, 14).TextColor(k.TextMuted)
|
||||
ui.Text(c, fmt.Sprintf("%s:%d", thread.File, thread.Line)).Font(theme.MonoFont).SingleLine().Grow(1).MinWidth(0)
|
||||
if thread.Resolved {
|
||||
ui.Row(c).AlignItems(ui.Center).Gap(u).TextColor(k.Success).Children(func() {
|
||||
ui.Icon(c, icons.Must("circle-check")).Size(14, 14)
|
||||
ui.Text(c, core.Msg(c, "review_comment.resolved")).FontWeight(600)
|
||||
})
|
||||
}
|
||||
})
|
||||
for i, m := range thread.Messages {
|
||||
ui.Column(c).Gap(u).Padding(u*3, u*4).Children(func() {
|
||||
if i > 0 {
|
||||
ui.Box(c).Height(1).Background(k.Border).Margin(0, 0, u*2, 0).Role(ui.RoleNone)
|
||||
}
|
||||
ui.Row(c).AlignItems(ui.Center).Gap(u * 2).Children(func() {
|
||||
display.Avatar(c, m.Author, display.AvatarOptions{Size: 20})
|
||||
ui.Text(c, m.Author).FontWeight(600).SingleLine()
|
||||
ui.Text(c, m.When.Format(layout)).FontSize(small).TextColor(k.TextMuted).FontFeatures("tnum")
|
||||
})
|
||||
ui.Text(c, m.Body)
|
||||
})
|
||||
}
|
||||
ui.Box(c).Height(1).Background(k.Border).Role(ui.RoleNone)
|
||||
ui.Column(c).Gap(u * 2).Padding(u * 3).AlignItems(ui.Stretch).Children(func() {
|
||||
if !thread.Resolved {
|
||||
in := input.TextArea(c, draft, input.TextAreaOptions{Placeholder: core.Msg(c, "review_comment.placeholder"), Label: core.Msg(c, "review_comment.field"),
|
||||
MinHeight: 56, MaxHeight: 160, Disabled: opts.Disabled})
|
||||
send := in.Submitted()
|
||||
ui.Row(c).Gap(u * 2).Justify(ui.End).Children(func() {
|
||||
if input.Button(c, core.Msg(c, "review_comment.resolve"), input.ButtonOptions{Variant: input.Secondary, Icon: icons.Must("check"), Disabled: opts.Disabled}).Clicked() {
|
||||
res.resolved = true
|
||||
}
|
||||
if input.Button(c, core.Msg(c, "review_comment.reply"), input.ButtonOptions{Icon: icons.Must("reply"), Disabled: opts.Disabled || strings.TrimSpace(*draft) == ""}).Clicked() {
|
||||
send = true
|
||||
}
|
||||
})
|
||||
if send && !card.IsDisabled() && strings.TrimSpace(*draft) != "" {
|
||||
res.reply, *draft = strings.TrimSpace(*draft), ""
|
||||
}
|
||||
return
|
||||
}
|
||||
ui.Row(c).Justify(ui.End).Children(func() {
|
||||
if input.Button(c, core.Msg(c, "review_comment.reopen"), input.ButtonOptions{Variant: input.Secondary, Icon: icons.Must("rotate-ccw"), Disabled: opts.Disabled}).Clicked() {
|
||||
res.reopened = true
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
if card.IsDisabled() {
|
||||
res.resolved, res.reopened = false, false
|
||||
}
|
||||
switch {
|
||||
case res.reply != "":
|
||||
slog.Debug("mujica: review comment reply", "file", thread.File, "line", thread.Line)
|
||||
case res.resolved:
|
||||
slog.Debug("mujica: review comment resolve", "file", thread.File, "line", thread.Line)
|
||||
case res.reopened:
|
||||
slog.Debug("mujica: review comment reopen", "file", thread.File, "line", thread.Line)
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func reviewSample() ReviewThread {
|
||||
return ReviewThread{File: "commit_graph.go", Line: 88, Messages: []ReviewMessage{
|
||||
{Author: "Uika", When: gitTestTime, Body: "Should merge lanes reuse the parent's color?"},
|
||||
{Author: "Sakiko", When: gitTestTime, Body: "Yes, the first parent keeps it."},
|
||||
}}
|
||||
}
|
||||
|
||||
func TestReviewCommentReplyResolveReopen(t *testing.T) {
|
||||
thread := reviewSample()
|
||||
draft := ""
|
||||
var replies []string
|
||||
resolves, reopens := 0, 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := ReviewComment(c, thread, &draft, ReviewCommentOptions{})
|
||||
if s, ok := r.Replied(); ok {
|
||||
replies = append(replies, s)
|
||||
}
|
||||
if r.Resolved() {
|
||||
resolves++
|
||||
thread.Resolved = true
|
||||
}
|
||||
if r.Reopened() {
|
||||
reopens++
|
||||
thread.Resolved = false
|
||||
}
|
||||
}, 600, 500)
|
||||
if !tt.HasText("commit_graph.go:88") || !tt.HasText("Yes, the first parent keeps it.") {
|
||||
t.Fatalf("thread: %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Reply")
|
||||
if len(replies) != 0 {
|
||||
t.Fatal("empty reply sent")
|
||||
}
|
||||
if err := tt.Click("Reply text"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Type(" Agreed. ")
|
||||
tt.Key(ui.Cmd, ui.KeyEnter)
|
||||
if len(replies) != 1 || replies[0] != "Agreed." || draft != "" {
|
||||
t.Fatalf("replies %q draft %q", replies, draft)
|
||||
}
|
||||
_ = tt.Click("Resolve")
|
||||
if resolves != 1 || !tt.HasText("Resolved") || tt.HasText("Reply text") {
|
||||
t.Fatalf("resolve: %d %q", resolves, tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Reopen")
|
||||
if reopens != 1 || !tt.HasText("Reply") {
|
||||
t.Fatalf("reopen: %d", reopens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewCommentDisabledAndInvalid(t *testing.T) {
|
||||
draft := "Text"
|
||||
acted := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := ReviewComment(c, reviewSample(), &draft, ReviewCommentOptions{Disabled: true})
|
||||
_, replied := r.Replied()
|
||||
acted = acted || replied || r.Resolved()
|
||||
}, 600, 500)
|
||||
_ = tt.Click("Resolve")
|
||||
_ = tt.Click("Reply")
|
||||
if acted || draft != "Text" {
|
||||
t.Fatal("disabled thread acted")
|
||||
}
|
||||
for _, th := range []ReviewThread{{Line: 1, Messages: reviewSample().Messages}, {File: "a", Messages: reviewSample().Messages}, {File: "a", Line: 1}} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%+v did not panic", th)
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ReviewComment(c, th, &draft, ReviewCommentOptions{})
|
||||
}, 300, 100, 1)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewCommentSnapshot(t *testing.T) {
|
||||
snapshot(t, "review_comment", 560, 420, func(c *ui.Context) {
|
||||
draft := ui.Local(c.Root(), "draft", func() string { return "" })
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
ReviewComment(c, reviewSample(), draft, ReviewCommentOptions{})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// Stash is one stash entry.
|
||||
type Stash struct {
|
||||
// Ref names the entry, as stash@{0}.
|
||||
Ref string
|
||||
Message string
|
||||
Branch string
|
||||
}
|
||||
|
||||
// StashListOptions configure a StashList.
|
||||
type StashListOptions struct {
|
||||
// Status and Error show the load state.
|
||||
Status data.DataStatus
|
||||
Error string
|
||||
}
|
||||
|
||||
// StashList shows stashes with apply, pop and drop actions.
|
||||
func StashList(c *ui.Context, s *data.ListState[string], stashes []Stash, opts StashListOptions) GitListResult {
|
||||
var r GitListResult
|
||||
k := core.Tokens(c)
|
||||
actions := []GitAction{
|
||||
{ID: "apply", Label: core.Msg(c, "stash_list.apply"), Icon: "check"},
|
||||
{ID: "pop", Label: core.Msg(c, "stash_list.pop"), Icon: "upload"},
|
||||
{ID: "drop", Label: core.Msg(c, "stash_list.drop"), Icon: "trash-2"},
|
||||
}
|
||||
v := data.List(c, s, len(stashes), data.ListOptions[string]{
|
||||
Key: func(i int) string { return stashListKey(stashes[i]) },
|
||||
Label: func(i int) string { return stashes[i].Message },
|
||||
Status: opts.Status, Error: opts.Error, Empty: core.Msg(c, "stash_list.empty"),
|
||||
}, func(i int) {
|
||||
st := stashes[i]
|
||||
small := core.FontSize(c, theme.CaptionSize)
|
||||
ui.Icon(c, icons.Must("archive")).Size(14, 14).Shrink(0).TextColor(k.TextMuted)
|
||||
ui.Text(c, st.Ref).Font(theme.MonoFont).FontSize(small).TextColor(k.AccentText).Shrink(0)
|
||||
ui.Text(c, st.Message).SingleLine().Grow(1).MinWidth(0)
|
||||
if st.Branch != "" {
|
||||
ui.Text(c, st.Branch).FontSize(small).TextColor(k.TextMuted).SingleLine().MaxWidth(140).Shrink(0)
|
||||
}
|
||||
gitRowActions(c, actions, st.Ref, &r)
|
||||
})
|
||||
r.Element, r.changed, r.submitted = v.Element, v.Changed(), v.Submitted()
|
||||
return r
|
||||
}
|
||||
|
||||
func stashListKey(s Stash) string {
|
||||
if s.Ref == "" {
|
||||
panic("mujica: Stash needs a Ref")
|
||||
}
|
||||
return s.Ref
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
)
|
||||
|
||||
func stashSample() []Stash {
|
||||
return []Stash{
|
||||
{Ref: "stash@{0}", Message: "WIP on main: graph lanes", Branch: "main"},
|
||||
{Ref: "stash@{1}", Message: "Experiment with blame colors", Branch: "fix/blame"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestStashListActions(t *testing.T) {
|
||||
var s data.ListState[string]
|
||||
var acted [][2]string
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := StashList(c, &s, stashSample(), StashListOptions{})
|
||||
r.Element.Height(200)
|
||||
if a, item, ok := r.Action(); ok {
|
||||
acted = append(acted, [2]string{a, item})
|
||||
}
|
||||
}, 700, 240)
|
||||
r, _ := tt.Find("Experiment with blame colors")
|
||||
y := r.Y + r.H/2
|
||||
// Apply, Pop and Drop sit at the right end, 28 DIPs each.
|
||||
for _, x := range []float32{700 - 90, 700 - 60, 700 - 30} {
|
||||
tt.ClickAt(x, y)
|
||||
}
|
||||
want := [][2]string{{"apply", "stash@{1}"}, {"pop", "stash@{1}"}, {"drop", "stash@{1}"}}
|
||||
if len(acted) != 3 || acted[0] != want[0] || acted[1] != want[1] || acted[2] != want[2] {
|
||||
t.Fatalf("actions %q", acted)
|
||||
}
|
||||
for _, name := range []string{"Apply", "Pop", "Drop"} {
|
||||
if _, ok := tt.Find(name); !ok {
|
||||
t.Errorf("no %s button", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStashListEmptyAndInvalid(t *testing.T) {
|
||||
var s data.ListState[string]
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{Locale: core.ZhCN})
|
||||
StashList(c, &s, nil, StashListOptions{})
|
||||
}, 400, 200)
|
||||
if !tt.HasText("没有贮藏") {
|
||||
t.Fatalf("empty: %q", tt.Texts())
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
var s data.ListState[string]
|
||||
StashList(c, &s, []Stash{{Message: "x"}}, StashListOptions{}).Element.Height(100)
|
||||
}, 300, 120, 1)
|
||||
}
|
||||
|
||||
func TestStashListSnapshot(t *testing.T) {
|
||||
snapshot(t, "stash_list", 640, 140, func(c *ui.Context) {
|
||||
s := ui.Local(c.Root(), "list", func() *data.ListState[string] { return &data.ListState[string]{} })
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
StashList(c, *s, stashSample(), StashListOptions{}).Element.Height(100)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// GitTag is a tag and the commit it names.
|
||||
type GitTag struct {
|
||||
Name string
|
||||
Target string
|
||||
// Annotation is the tag message; empty for a lightweight tag.
|
||||
Annotation string
|
||||
}
|
||||
|
||||
// TagListOptions configure a TagList.
|
||||
type TagListOptions struct {
|
||||
Status data.DataStatus
|
||||
Error string
|
||||
}
|
||||
|
||||
// TagList shows tags; Action "jump" carries the target hash.
|
||||
func TagList(c *ui.Context, s *data.ListState[string], tags []GitTag, opts TagListOptions) GitListResult {
|
||||
var r GitListResult
|
||||
k := core.Tokens(c)
|
||||
v := data.List(c, s, len(tags), data.ListOptions[string]{
|
||||
Key: func(i int) string { return tagListKey(tags[i]) },
|
||||
Label: func(i int) string { return tags[i].Name },
|
||||
Status: opts.Status, Error: opts.Error, Empty: core.Msg(c, "tag_list.empty"),
|
||||
}, func(i int) {
|
||||
t := tags[i]
|
||||
small := core.FontSize(c, theme.CaptionSize)
|
||||
ui.Icon(c, icons.Must("tag")).Size(14, 14).Shrink(0).TextColor(k.TextMuted)
|
||||
ui.Text(c, t.Name).FontWeight(600).SingleLine().Shrink(0).MaxWidth(200)
|
||||
ui.Text(c, t.Annotation).FontSize(small).TextColor(k.TextMuted).SingleLine().Grow(1).MinWidth(0)
|
||||
ui.Text(c, gitShortHash(t.Target)).Font(theme.MonoFont).FontSize(small).TextColor(k.AccentText).Shrink(0)
|
||||
gitRowActions(c, []GitAction{{ID: "jump", Label: core.Msg(c, "tag_list.jump"), Icon: "git-commit-horizontal"}}, t.Target, &r)
|
||||
})
|
||||
r.Element, r.changed, r.submitted = v.Element, v.Changed(), v.Submitted()
|
||||
return r
|
||||
}
|
||||
|
||||
func tagListKey(t GitTag) string {
|
||||
if t.Name == "" || t.Target == "" {
|
||||
panic("mujica: GitTag needs a Name and a Target")
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
)
|
||||
|
||||
func tagSample() []GitTag {
|
||||
return []GitTag{
|
||||
{Name: "v1.1.0", Target: gitTestHash(42), Annotation: "Five hundred components"},
|
||||
{Name: "v1.0.0", Target: gitTestHash(7)},
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagListJumpAndSelect(t *testing.T) {
|
||||
var s data.ListState[string]
|
||||
jump := ""
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := TagList(c, &s, tagSample(), TagListOptions{})
|
||||
r.Element.Height(200)
|
||||
if a, item, ok := r.Action(); ok && a == "jump" {
|
||||
jump = item
|
||||
}
|
||||
}, 600, 240)
|
||||
if !tt.HasText("Five hundred components") || !tt.HasText(gitShortHash(gitTestHash(42))) {
|
||||
t.Fatalf("row: %q", tt.Texts())
|
||||
}
|
||||
if err := tt.Click("v1.0.0"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if key, _ := s.Selected(); key != "v1.0.0" {
|
||||
t.Fatalf("selected %q", key)
|
||||
}
|
||||
if err := tt.Click("Go to commit"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if jump != gitTestHash(42) {
|
||||
t.Fatalf("jump %q", jump)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagListInvalid(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
var s data.ListState[string]
|
||||
TagList(c, &s, []GitTag{{Name: "v1"}}, TagListOptions{}).Element.Height(100)
|
||||
}, 300, 120, 1)
|
||||
}
|
||||
|
||||
func TestTagListSnapshot(t *testing.T) {
|
||||
snapshot(t, "tag_list", 600, 140, func(c *ui.Context) {
|
||||
s := ui.Local(c.Root(), "list", func() *data.ListState[string] { return &data.ListState[string]{} })
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
TagList(c, *s, tagSample(), TagListOptions{}).Element.Height(100)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// MergeChoice is how a conflict is resolved.
|
||||
type MergeChoice int
|
||||
|
||||
const (
|
||||
MergeUnresolved MergeChoice = iota
|
||||
// MergeOurs takes the local (current) side.
|
||||
MergeOurs
|
||||
// MergeTheirs takes the remote (incoming) side.
|
||||
MergeTheirs
|
||||
// MergeBoth takes ours, then theirs.
|
||||
MergeBoth
|
||||
// MergeBase keeps the common ancestor.
|
||||
MergeBase
|
||||
)
|
||||
|
||||
// mergeChunk is a merge run; stable when all three agree.
|
||||
type mergeChunk struct {
|
||||
base, ours, theirs []string
|
||||
conflict bool
|
||||
// auto is the merged text of a non-conflicting change.
|
||||
auto []string
|
||||
stable bool
|
||||
}
|
||||
|
||||
// ThreeWayMergeState holds a merge's chunks and conflict choices.
|
||||
type ThreeWayMergeState struct {
|
||||
list ui.ListState
|
||||
base, ours, theirs string
|
||||
chunks []mergeChunk
|
||||
choices []MergeChoice
|
||||
built bool
|
||||
}
|
||||
|
||||
// Resolved returns chosen conflicts and all conflicts.
|
||||
func (s *ThreeWayMergeState) Resolved() (done, total int) {
|
||||
for i, ch := range s.chunks {
|
||||
if ch.conflict {
|
||||
total++
|
||||
if s.choices[i] != MergeUnresolved {
|
||||
done++
|
||||
}
|
||||
}
|
||||
}
|
||||
return done, total
|
||||
}
|
||||
|
||||
// Result is the merged text; unresolved conflicts keep conflict markers.
|
||||
func (s *ThreeWayMergeState) Result() string {
|
||||
var out []string
|
||||
for i, ch := range s.chunks {
|
||||
out = append(out, mergeChunkText(ch, s.choices[i])...)
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
func mergeChunkText(ch mergeChunk, choice MergeChoice) []string {
|
||||
switch {
|
||||
case ch.stable:
|
||||
return ch.base
|
||||
case !ch.conflict:
|
||||
return ch.auto
|
||||
}
|
||||
switch choice {
|
||||
case MergeOurs:
|
||||
return ch.ours
|
||||
case MergeTheirs:
|
||||
return ch.theirs
|
||||
case MergeBoth:
|
||||
return slices.Concat(ch.ours, ch.theirs)
|
||||
case MergeBase:
|
||||
return ch.base
|
||||
}
|
||||
return slices.Concat([]string{"<<<<<<< ours"}, ch.ours, []string{"======="}, ch.theirs, []string{">>>>>>> theirs"})
|
||||
}
|
||||
|
||||
// ThreeWayMergeOptions configure a ThreeWayMerge.
|
||||
type ThreeWayMergeOptions struct {
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// ThreeWayMerge shows four columns per chunk; give it a height.
|
||||
func ThreeWayMerge(c *ui.Context, s *ThreeWayMergeState, base, ours, theirs string, opts ThreeWayMergeOptions) core.ChoiceResult {
|
||||
if !s.built || s.base != base || s.ours != ours || s.theirs != theirs {
|
||||
s.chunks = mergeChunks(internal.SplitLines(base), internal.SplitLines(ours), internal.SplitLines(theirs))
|
||||
s.choices = make([]MergeChoice, len(s.chunks))
|
||||
s.base, s.ours, s.theirs, s.built = base, ours, theirs, true
|
||||
_, total := s.Resolved()
|
||||
slog.Debug("mujica: three way merge chunks", "chunks", len(s.chunks), "conflicts", total)
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
col := ui.Column(c).AlignItems(ui.Stretch).Disabled(opts.Disabled).Background(k.Surface).
|
||||
Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius).Clip()
|
||||
res := core.ChoiceResult{Element: col}
|
||||
col.Children(func() {
|
||||
done, total := s.Resolved()
|
||||
ui.Row(c).AlignItems(ui.Center).Gap(u*3).Padding(u*2, u*3).Background(k.Background).Children(func() {
|
||||
ui.Text(c, fmt.Sprintf(core.Msg(c, "three_way_merge.resolved"), done, total)).FontWeight(600).Role(ui.RoleStatus)
|
||||
})
|
||||
ui.Grid(c).ColumnTracks(ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1)).Background(k.Background).Children(func() {
|
||||
for _, key := range []string{"ours", "base", "theirs", "result"} {
|
||||
ui.Text(c, core.Msg(c, "three_way_merge."+key)).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.CaptionSize)).
|
||||
TextColor(k.Ornament.Mix(k.Text, 0.35)).Padding(u, u*3)
|
||||
}
|
||||
})
|
||||
ui.List(c, &s.list, len(s.chunks), func(i int) {
|
||||
if threeWayMergeChunk(c, s, i) && !col.IsDisabled() {
|
||||
res = core.NewChoiceResult(res.Element, true)
|
||||
}
|
||||
}).Grow(1)
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
// threeWayMergeChunk builds chunk i and reports a new choice.
|
||||
func threeWayMergeChunk(c *ui.Context, s *ThreeWayMergeState, i int) bool {
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
ch := s.chunks[i]
|
||||
changed := false
|
||||
ui.Column(c).AlignItems(ui.Stretch).Children(func() {
|
||||
grid := ui.Grid(c).ColumnTracks(ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1))
|
||||
grid.Draw(func(p *ui.Painter, r ui.Rect) { p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W, H: 1}, k.Border, 0) })
|
||||
grid.Children(func() {
|
||||
tint := func(lines []string, col ui.Color, on bool) {
|
||||
cell := ui.Column(c).Padding(u, u*3).MinWidth(0).Clip()
|
||||
if on {
|
||||
cell.Background(col.Mix(k.Surface, 0.88))
|
||||
}
|
||||
cell.Children(func() { mergeLines(c, lines, ch.stable) })
|
||||
}
|
||||
if ch.stable {
|
||||
tint(ch.base, k.Text, false)
|
||||
tint(ch.base, k.Text, false)
|
||||
tint(ch.base, k.Text, false)
|
||||
tint(ch.base, k.Text, false)
|
||||
return
|
||||
}
|
||||
choice := s.choices[i]
|
||||
tint(ch.ours, k.Info, ch.conflict && (choice == MergeOurs || choice == MergeBoth) || !ch.conflict && slices.Equal(ch.auto, ch.ours))
|
||||
tint(ch.base, k.TextMuted, choice == MergeBase)
|
||||
tint(ch.theirs, k.Warning, ch.conflict && (choice == MergeTheirs || choice == MergeBoth) || !ch.conflict && slices.Equal(ch.auto, ch.theirs))
|
||||
result := ui.Column(c).Padding(u, u*3).MinWidth(0).Clip()
|
||||
result.Children(func() {
|
||||
if ch.conflict && choice == MergeUnresolved {
|
||||
ui.Row(c).Gap(u).AlignItems(ui.Center).TextColor(k.Danger).Children(func() {
|
||||
ui.Icon(c, icons.Must("triangle-alert")).Size(14, 14)
|
||||
ui.Text(c, core.Msg(c, "three_way_merge.unresolved")).FontSize(core.FontSize(c, theme.CaptionSize))
|
||||
})
|
||||
return
|
||||
}
|
||||
mergeLines(c, mergeChunkText(ch, choice), false)
|
||||
})
|
||||
})
|
||||
if !ch.conflict {
|
||||
return
|
||||
}
|
||||
ui.Row(c).Gap(u*2).Padding(u, u*3, u*2, u*3).Wrap().Children(func() {
|
||||
for _, opt := range []struct {
|
||||
choice MergeChoice
|
||||
key string
|
||||
}{{MergeOurs, "take_ours"}, {MergeTheirs, "take_theirs"}, {MergeBoth, "take_both"}, {MergeBase, "take_base"}} {
|
||||
if mergeChoiceButton(c, core.Msg(c, "three_way_merge."+opt.key), s.choices[i] == opt.choice).Clicked() && s.choices[i] != opt.choice {
|
||||
s.choices[i], changed = opt.choice, true
|
||||
slog.Debug("mujica: three way merge choose", "chunk", i, "choice", opt.key)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
return changed
|
||||
}
|
||||
|
||||
// mergeChoiceButton is a toggle marking the chosen side.
|
||||
func mergeChoiceButton(c *ui.Context, label string, on bool) *ui.Element {
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).Padding(0, u*3).Gap(u*1.5).Radius(theme.ControlRadius).
|
||||
Border(theme.BorderWidth, k.ControlBorder).Label(label).TextColor(k.Text)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
switch {
|
||||
case on:
|
||||
b.Background(k.Selection).Border(theme.BorderWidth, k.AccentText).FontWeight(600)
|
||||
case b.Pressed():
|
||||
b.Background(k.Selection)
|
||||
case b.Hovered():
|
||||
b.Background(k.SurfaceHover)
|
||||
}
|
||||
b.Transition(core.ColorFade)
|
||||
b.Children(func() {
|
||||
if on {
|
||||
ui.Icon(c, icons.Must("check")).Size(14, 14).TextColor(k.AccentText)
|
||||
}
|
||||
ui.Text(c, label).FontSize(core.FontSize(c, theme.CaptionSize)).SingleLine()
|
||||
})
|
||||
return b
|
||||
}
|
||||
|
||||
// mergeLines shows code lines, folding long stable runs.
|
||||
func mergeLines(c *ui.Context, lines []string, fold bool) {
|
||||
k := core.Tokens(c)
|
||||
show := func(ls []string) {
|
||||
for _, l := range ls {
|
||||
if l == "" {
|
||||
l = " "
|
||||
}
|
||||
t := ui.Text(c, l).Font(theme.MonoFont).NoWrap().FontSize(core.FontSize(c, theme.CaptionSize))
|
||||
if fold {
|
||||
t.TextColor(k.TextMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
ui.Text(c, "∅").TextColor(k.TextMuted).Label(core.Msg(c, "three_way_merge.empty"))
|
||||
return
|
||||
}
|
||||
if !fold || len(lines) <= 5 {
|
||||
show(lines)
|
||||
return
|
||||
}
|
||||
show(lines[:2])
|
||||
ui.Text(c, fmt.Sprintf(core.Msg(c, "diff_viewer.fold"), len(lines)-4)).Font(theme.SerifFont).
|
||||
FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
show(lines[len(lines)-2:])
|
||||
}
|
||||
|
||||
// mergeHunk replaces base lines [from, to) with lines.
|
||||
type mergeHunk struct {
|
||||
from, to int
|
||||
lines []string
|
||||
}
|
||||
|
||||
// mergeHunks lists the changes a diff makes to base.
|
||||
func mergeHunks(d []internal.DiffLine) []mergeHunk {
|
||||
var out []mergeHunk
|
||||
at, open := 0, -1
|
||||
for _, l := range d {
|
||||
if l.Op == internal.DiffEqual {
|
||||
at = l.Old
|
||||
open = -1
|
||||
continue
|
||||
}
|
||||
if open < 0 {
|
||||
out = append(out, mergeHunk{from: at, to: at})
|
||||
open = len(out) - 1
|
||||
}
|
||||
h := &out[open]
|
||||
if l.Op == internal.DiffDelete {
|
||||
h.to = l.Old
|
||||
} else {
|
||||
h.lines = append(h.lines, l.Text)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeChunks splits a merge into stable, clean and conflicting runs.
|
||||
func mergeChunks(base, ours, theirs []string) []mergeChunk {
|
||||
type tagged struct {
|
||||
mergeHunk
|
||||
side int
|
||||
}
|
||||
var all []tagged
|
||||
for side, other := range [][]string{ours, theirs} {
|
||||
for _, h := range mergeHunks(internal.LineDiff(base, other)) {
|
||||
all = append(all, tagged{h, side})
|
||||
}
|
||||
}
|
||||
slices.SortStableFunc(all, func(a, b tagged) int { return a.from - b.from })
|
||||
var chunks []mergeChunk
|
||||
at := 0
|
||||
for i := 0; i < len(all); {
|
||||
from, to := all[i].from, all[i].to
|
||||
j := i + 1
|
||||
for j < len(all) && (all[j].from < to || all[j].from == to && (all[j].from == all[j].to || from == to)) {
|
||||
to = max(to, all[j].to)
|
||||
j++
|
||||
}
|
||||
if from > at {
|
||||
chunks = append(chunks, mergeChunk{base: base[at:from], stable: true})
|
||||
}
|
||||
sides := [2][]string{}
|
||||
touched := [2]bool{}
|
||||
for s := range 2 {
|
||||
cur := from
|
||||
for _, h := range all[i:j] {
|
||||
if h.side != s {
|
||||
continue
|
||||
}
|
||||
touched[s] = true
|
||||
sides[s] = append(sides[s], base[cur:h.from]...)
|
||||
sides[s] = append(sides[s], h.lines...)
|
||||
cur = h.to
|
||||
}
|
||||
sides[s] = append(sides[s], base[cur:to]...)
|
||||
}
|
||||
ch := mergeChunk{base: base[from:to], ours: sides[0], theirs: sides[1]}
|
||||
switch {
|
||||
case !touched[1]:
|
||||
ch.auto = ch.ours
|
||||
case !touched[0], slices.Equal(ch.ours, ch.theirs):
|
||||
ch.auto = ch.theirs
|
||||
default:
|
||||
ch.conflict = true
|
||||
}
|
||||
chunks = append(chunks, ch)
|
||||
at, i = to, j
|
||||
}
|
||||
if at < len(base) {
|
||||
chunks = append(chunks, mergeChunk{base: base[at:], stable: true})
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
const (
|
||||
mergeBase = "a\nb\nc\nd\ne\nf"
|
||||
mergeOurs = "a\nB1\nc\nd\ne\nf\ng"
|
||||
mergeTheirs = "a\nB2\nc\nD\ne\nf"
|
||||
)
|
||||
|
||||
func TestMergeChunks(t *testing.T) {
|
||||
chunks := mergeChunks([]string{"a", "b", "c", "d", "e", "f"}, []string{"a", "B1", "c", "d", "e", "f", "g"}, []string{"a", "B2", "c", "D", "e", "f"})
|
||||
// a | b conflict | c | d→D clean | e f | +g clean
|
||||
if len(chunks) != 6 {
|
||||
t.Fatalf("%d chunks: %+v", len(chunks), chunks)
|
||||
}
|
||||
if !chunks[1].conflict || chunks[1].ours[0] != "B1" || chunks[1].theirs[0] != "B2" {
|
||||
t.Errorf("conflict chunk %+v", chunks[1])
|
||||
}
|
||||
if chunks[3].conflict || chunks[3].auto[0] != "D" {
|
||||
t.Errorf("clean remote change %+v", chunks[3])
|
||||
}
|
||||
if chunks[5].conflict || len(chunks[5].auto) != 1 || chunks[5].auto[0] != "g" {
|
||||
t.Errorf("clean local append %+v", chunks[5])
|
||||
}
|
||||
same := mergeChunks([]string{"x"}, []string{"y"}, []string{"y"})
|
||||
if len(same) != 1 || same[0].conflict {
|
||||
t.Errorf("identical changes conflict: %+v", same)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThreeWayMergeTakeSides(t *testing.T) {
|
||||
var s ThreeWayMergeState
|
||||
changes := 0
|
||||
disabled := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := ThreeWayMerge(c, &s, mergeBase, mergeOurs, mergeTheirs, ThreeWayMergeOptions{Disabled: disabled})
|
||||
r.Element.Height(560)
|
||||
if r.Changed() {
|
||||
changes++
|
||||
}
|
||||
}, 900, 600)
|
||||
if !tt.HasText("0 of 1 conflicts resolved") || !tt.HasText("Unresolved") {
|
||||
t.Fatalf("start: %q", tt.Texts())
|
||||
}
|
||||
if got := s.Result(); got != "a\n<<<<<<< ours\nB1\n=======\nB2\n>>>>>>> theirs\nc\nD\ne\nf\ng" {
|
||||
t.Fatalf("unresolved result %q", got)
|
||||
}
|
||||
if err := tt.Click("Take remote"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Result() != "a\nB2\nc\nD\ne\nf\ng" || changes != 1 || !tt.HasText("1 of 1 conflicts resolved") {
|
||||
t.Fatalf("take remote: %q changes %d", s.Result(), changes)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
_ = tt.Click("Take both")
|
||||
if s.Result() != "a\nB1\nB2\nc\nD\ne\nf\ng" {
|
||||
t.Fatalf("take both: %q", s.Result())
|
||||
}
|
||||
disabled = true
|
||||
tt.Frame()
|
||||
_ = tt.Click("Keep base")
|
||||
if s.Result() != "a\nB1\nB2\nc\nD\ne\nf\ng" || changes != 2 {
|
||||
t.Fatalf("disabled merge changed: %q", s.Result())
|
||||
}
|
||||
}
|
||||
|
||||
func TestThreeWayMergeSnapshot(t *testing.T) {
|
||||
snapshot(t, "three_way_merge", 860, 420, func(c *ui.Context) {
|
||||
s := ui.Local(c.Root(), "m", func() *ThreeWayMergeState { return &ThreeWayMergeState{} })
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
ThreeWayMerge(c, *s, mergeBase, mergeOurs, mergeTheirs, ThreeWayMergeOptions{}).Element.Height(380)
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user