Split into category packages; module github.com/ZacharyZhang-NY/MujicaUI

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 16:18:48 -04:00
1 parent 5a7e921c7e
commit 22b53e9e9e
1822 files changed
+24568 -21314

No files matched your search

+68
View File
@@ -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
}
+60
View File
@@ -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{})
})
})
}
+108
View File
@@ -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
}
+98
View File
@@ -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)
})
})
}
+122
View File
@@ -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)
}
}
}
+88
View File
@@ -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)
})
})
}
+214
View File
@@ -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
}
+87
View File
@@ -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")
})
}
+165
View File
@@ -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
}
+97
View File
@@ -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)
})
})
}
+149
View File
@@ -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)
}
+108
View File
@@ -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{})
})
})
}
+206
View File
@@ -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)
}
+100
View File
@@ -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)
})
})
}
+108
View File
@@ -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
}
+103
View File
@@ -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{})
})
})
}
+107
View File
@@ -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
}
+94
View File
@@ -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)
})
})
}
+221
View File
@@ -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
}
+88
View File
@@ -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") })
}
+73
View File
@@ -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
}
+62
View File
@@ -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})
})
})
}
+226
View File
@@ -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
}
+106
View File
@@ -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)
})
})
}
+2
View File
@@ -0,0 +1,2 @@
// Package git holds MujicaUI version control views: diffs, commits, branches and merges.
package git
+266
View File
@@ -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)
}
+50
View File
@@ -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
}
+68
View File
@@ -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)
})
})
}
+73
View File
@@ -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
}
+67
View File
@@ -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})
}
})
})
})
}
+30
View File
@@ -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})
}
+67
View File
@@ -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)
})
}
+51
View File
@@ -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})
})
})
}
+181
View File
@@ -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) })
})
}
+84
View File
@@ -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{})
})
})
}
+130
View File
@@ -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
}
+99
View File
@@ -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{})
})
})
}
+60
View File
@@ -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
}
+75
View File
@@ -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)
})
})
}
+52
View File
@@ -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
}
+67
View File
@@ -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)
})
})
}
+328
View File
@@ -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
}
+82
View File
@@ -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)
})
})
}