Merge branch 'worktree-agent-acb1218b465c60e42'

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 14:38:04 -04:00
commit 4405a5911e
121 files changed
+7031 -2

No files matched your search

+67
View File
@@ -0,0 +1,67 @@
package mujica
import (
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"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 := 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
}
+59
View File
@@ -0,0 +1,59 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
func TestAnsiTextStripsSGRKeepsUnknown(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, 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{})
})
})
}
+107
View File
@@ -0,0 +1,107 @@
package mujica
import (
"fmt"
"log/slog"
"strconv"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"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 := Tokens(c)
d := 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(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(colorFade)
gut.Children(func() {
if !head {
return
}
small := 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(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
}
+96
View File
@@ -0,0 +1,96 @@
package mujica
import (
"fmt"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
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) {
Use(c, 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) {
Use(c, 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) {
Use(c, 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)
})
})
}
+120
View File
@@ -0,0 +1,120 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"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 DataStatus
Error string
}
// BranchList shows branches with upstream and ahead/behind counts.
func BranchList(c *ui.Context, s *ListState[string], branches []Branch, opts BranchListOptions) GitListResult {
gitActionsCheck(opts.Actions)
var r GitListResult
v := List(c, s, len(branches), 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: 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 := Tokens(c)
u := Density(c).Unit()
small := 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, 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(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(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 := 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)
}
}
}
+85
View File
@@ -0,0 +1,85 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
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 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) {
Use(c, 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) {
Use(c, Settings{})
var s 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() *ListState[string] { return &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)
})
})
}
+211
View File
@@ -0,0 +1,211 @@
package mujica
import (
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"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 := Tokens(c)
d := 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(msg(c, "branch_selector.label") + ": " + *selected)
internal.Ring(tr, k.Focus, theme.ControlRadius)
if tr.Hovered() {
tr.Background(k.SurfaceHover)
}
tr.Transition(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) {
overlayStyle(c, panel, overlayAnchored(c, panel, b))
panel.Margin(6, 0, 0, 0).Padding(u * 2).Gap(u * 2).Width(300).MaxHeight(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 := TextInput(c, &s.query, TextInputOptions{Placeholder: msg(c, "branch_selector.search"), Label: 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, 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, msg(c, []string{"branch_selector.local", "branch_selector.remote"}[g])).Font(theme.SerifFont).
FontSize(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), 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 := Tokens(c)
d := 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(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
}
+85
View File
@@ -0,0 +1,85 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestBranchSelectorSearchAndChoose(t *testing.T) {
sel, changes := "main", 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, 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) {
Use(c, 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")
})
}
+163
View File
@@ -0,0 +1,163 @@
package mujica
import (
"fmt"
"log/slog"
"path"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"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 := 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 := 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 := Tokens(c)
d := 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(msg(c, key), count)).Font(theme.SerifFont).FontWeight(600).
FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Ornament.Mix(k.Text, 0.35)).Grow(1)
b := iconAction(c, icons.Must(icon), msg(c, "changes_list."+all)).Disabled(count == 0)
if b.Tooltip(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(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 := iconAction(c, icons.Must(icon), msg(c, "changes_list."+id)+" "+cf.Path).Tooltip(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
}
+95
View File
@@ -0,0 +1,95 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
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) {
Use(c, 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) {
Use(c, 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) {
Use(c, 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)
})
})
}
+158
View File
@@ -0,0 +1,158 @@
package mujica
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// CommandRun is a command and its output from the caller.
type CommandRun struct {
Command string
// Output may hold ANSI escapes.
Output string
ExitCode int
Duration time.Duration
// Running hides ExitCode and Duration behind a live mark.
Running bool
}
// 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 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 := Tokens(c)
d := 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(colorFade)
turn := tr.Animate("mujica.turn", commandBlockTurn(*open), 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 iconAction(c, icons.Must("copy"), msg(c, "command_block.copy_command")).Clicked() && !box.IsDisabled() {
c.WriteClipboard(run.Command)
res.copied = "command"
}
if iconAction(c, icons.Must("file-text"), 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(msg(c, "command_block.output"))
out.Children(func() {
if run.Output == "" {
ui.Text(c, msg(c, "command_block.no_output")).TextColor(k.TextMuted).FontSize(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 CommandRun) {
k := Tokens(c)
u := Density(c).Unit()
ui.Row(c).AlignItems(ui.Center).Gap(u * 1.5).Shrink(0).FontSize(FontSize(c, theme.CaptionSize)).Children(func() {
if run.Running {
Spinner(c, SpinnerOptions{Label: 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(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)
}
+105
View File
@@ -0,0 +1,105 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func commandBlockSample() CommandRun {
return 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) {
Use(c, 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 := CommandRun{Command: "make", Output: "boom", ExitCode: 2, Duration: 90 * time.Second}
disabled := false
tt := ui.NewTester(func(c *ui.Context) {
Use(c, 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 []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) {
Use(c, 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, 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, CommandRun{Command: "git push origin main", ExitCode: 1, Duration: 640 * time.Millisecond, Output: "rejected"}, &b, CommandBlockOptions{})
})
})
}
+204
View File
@@ -0,0 +1,204 @@
package mujica
import (
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// CommitGraphState is a CommitGraph's selection and its lane layout.
type CommitGraphState struct {
List 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 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 := Density(c).Unit() * 4
width := float32(min(max(s.lanes, 1), maxLanes)) * lane
h := Density(c).ControlHeight()
v := List(c, &s.List, len(commits), 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: 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, 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)
}
+98
View File
@@ -0,0 +1,98 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
// 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) {
Use(c, 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) {
Use(c, 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)
})
})
}
+106
View File
@@ -0,0 +1,106 @@
package mujica
import (
"fmt"
"log/slog"
"strings"
"unicode/utf8"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"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 := Tokens(c)
u := 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 := TextInput(c, &m.Title, TextInputOptions{Placeholder: msg(c, "commit_input.title"), Label: msg(c, "commit_input.title"),
Disabled: opts.Disabled, ReadOnly: blocked,
Trailing: func() {
col := k.TextMuted
if n > limit {
col = k.Warning
}
name := fmt.Sprintf(msg(c, "commit_input.count"), n, limit)
if n > limit {
name = fmt.Sprintf(msg(c, "commit_input.long"), n-limit)
}
ui.Text(c, fmt.Sprintf("%d/%d", n, limit)).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").TextColor(col).Shrink(0).
Label(name).Tooltip(name)
}})
body := TextArea(c, &m.Body, TextAreaOptions{Placeholder: msg(c, "commit_input.body"), Label: 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 Button(c, msg(c, "commit_input.amend"), ButtonOptions{Variant: Secondary, Icon: icons.Must("rotate-ccw"), Disabled: blocked || empty}).Clicked() {
res.amend = true
}
if Button(c, msg(c, "commit_input.commit"), 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
}
+101
View File
@@ -0,0 +1,101 @@
package mujica
import (
"strings"
"testing"
"github.com/egoist/mygo/ui"
)
func TestCommitInputCommitOnceWhileBusy(t *testing.T) {
m := CommitMessage{}
busy := false
commits, amends := 0, 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, 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) {
Use(c, 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) {
Use(c, 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) {
Use(c, 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{})
})
})
}
+105
View File
@@ -0,0 +1,105 @@
package mujica
import (
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"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 DataStatus
Error string
}
// CommitList shows commits to choose from; give it a height.
func CommitList(c *ui.Context, s *ListState[string], commits []Commit, opts CommitListOptions) GitListResult {
layout := gitTimeLayout(opts.TimeFormat)
v := List(c, s, len(commits), 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: 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 := Tokens(c)
gitRefs(c, cm.Refs)
ui.Text(c, cm.Subject).SingleLine().Grow(1).MinWidth(0)
small := 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 := Tokens(c)
u := Density(c).Unit()
for _, ref := range refs {
ui.Text(c, ref).FontSize(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
}
+91
View File
@@ -0,0 +1,91 @@
package mujica
import (
"fmt"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
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 ListState[string]
changes, opens := 0, 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, 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 ListState[string]
tt := ui.NewTester(func(c *ui.Context) {
Use(c, 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) {
Use(c, Settings{})
var s 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() *ListState[string] { return &ListState[string]{} })
ui.Column(c).Padding(16).Children(func() {
CommitList(c, *s, commits, CommitListOptions{}).Element.Height(180)
})
})
}
+220
View File
@@ -0,0 +1,220 @@
package mujica
import (
"fmt"
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"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) 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 := Tokens(c)
u := Density(c).Unit()
col := ui.Column(c).AlignItems(ui.Stretch).Gap(u * 3).Disabled(opts.Disabled)
res := 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(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(msg(c, "conflict_resolver.broken"), s.bad)).Role(ui.RoleStatus)
})
}
if total == 0 && s.bad == 0 {
ui.Text(c, 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.changed = true
}
}
})
return res
}
// conflictBlock shows one conflict and its choice buttons.
func conflictBlock(c *ui.Context, s *ConflictResolverState, i, n int) bool {
k := Tokens(c)
u := 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(msg(c, "conflict_resolver.title"), n, p.line)).Font(theme.SerifFont).FontWeight(600)
ui.Text(c, msg(c, key)).FontSize(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(FontSize(c, theme.CaptionSize)).FontWeight(600).TextColor(col)
ui.Text(c, ref).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
})
mergeLines(c, lines, false)
})
}
side(msg(c, "conflict_resolver.current"), p.oursRef, p.ours, k.Info, choice == MergeOurs || choice == MergeBoth)
side(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, 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
}
+86
View File
@@ -0,0 +1,86 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
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) {
Use(c, 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) {
Use(c, 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) {
Use(c, 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") })
}
+72
View File
@@ -0,0 +1,72 @@
package mujica
import (
"fmt"
"math"
"github.com/egoist/mygo/ui"
"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 := Tokens(c)
u := Density(c).Unit()
plus, minus := diffStatBlocks(added, deleted, n)
row := ui.Row(c).AlignItems(ui.Center).Gap(u * 2).Shrink(0).
Label(fmt.Sprintf(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(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
}
+60
View File
@@ -0,0 +1,60 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
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) {
Use(c, 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) {
Use(c, 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})
})
})
}
+225
View File
@@ -0,0 +1,225 @@
package mujica
import (
"fmt"
"log/slog"
"strconv"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"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 := Tokens(c)
digits := float32(len(strconv.Itoa(max(len(s.diff), 1))))
numW := digits*8 + 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 := Tokens(c)
u := Density(c).Unit()
small := FontSize(c, theme.CaptionSize)
sign, col, name := " ", k.TextMuted, ""
switch d.Op {
case internal.DiffDelete:
sign, col, name = "−", k.Danger, msg(c, "diff_viewer.deleted")
case internal.DiffInsert:
sign, col, name = "+", k.Success, 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 := Tokens(c)
u := 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(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(colorFade)
b.Children(func() {
ui.Text(c, "⋯").FontWeight(700)
ui.Text(c, label).Font(theme.SerifFont).FontSize(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
}
+105
View File
@@ -0,0 +1,105 @@
package mujica
import (
"fmt"
"strings"
"testing"
"github.com/egoist/mygo/ui"
"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) {
Use(c, 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) {
Use(c, 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)
})
})
}
+52
View File
@@ -0,0 +1,52 @@
# 301 DiffViewer 差异查看器
## 用途
按行比较两段文本,统一或并排显示:行号、增删高亮、折叠较长的未变更区;大文件只构建可见行。
## 最小示例
```go
var state mujica.DiffViewerState
from := "func main() {\n\tprintln(\"hi\")\n}\n"
to := "func main() {\n\tprintln(\"hello\")\n}\n"
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.DiffViewer(c, &state, from, to, mujica.DiffViewerOptions{Language: "go", SideBySide: true}).Height(240)
}
ui.Render(view, 400, 300, 1)
```
签名:`func DiffViewer(c *ui.Context, s *DiffViewerState, from, to string, opts DiffViewerOptions) *ui.Element`
## 参数
`DiffViewerOptions`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| SideBySide | bool | false | 并排两栏;否则统一一栏。 |
| Context | int | 0(即 3) | 变更前后保留的未变更行数;为负 panic。 |
| Language | string | "" | 语法高亮语言,如 go;空为纯文本。 |
`DiffViewerState` 由调用方持有,保存差异、已展开的折叠与滚动位置。差异只在 from 或 to 改变时重新计算(Myers 行差异)。给返回元素一个高度。
## 状态
- 删除行:Danger 浅色底与 − 号;新增行:Success 浅色底与 + 号;不只靠颜色。
- 统一视图显示新旧两列行号;并排视图每侧一列,缺行的一侧为空底。
- 超过 2×Context 的未变更区折叠为“N 行未更改”按钮,点击展开,悬停 120 ms 过渡。
## 事件
- 无;展开折叠保存在状态中。
## 键盘操作
- 列表可获得焦点并滚动;折叠按钮用 Tab 到达,Enter 或 Space 展开。
## 限制
- 只做行级差异;词级差异见 InlineDiff。
- 长行不换行,超出部分裁剪。
- 文本换成新字符串后折叠全部重置。
+44
View File
@@ -0,0 +1,44 @@
# 302 InlineDiff 行内差异
## 用途
并列显示一行的旧版与新版,标出词级的删除与新增。
## 最小示例
```go
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.InlineDiff(c, "The court meets at noon", "The court meets at dusk", mujica.InlineDiffOptions{})
}
ui.Render(view, 400, 300, 1)
```
签名:`func InlineDiff(c *ui.Context, from, to string, opts InlineDiffOptions) *ui.Element`
## 参数
`InlineDiffOptions`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| Mono | bool | false | 使用等宽字体,适合代码。 |
词按字母数字、空白与单个标点切分;差异只在 from 或 to 改变时重新计算。
## 状态
- 旧行以 − 开头,删除的词带浅色底与删除线;新行以 + 开头,新增的词带浅色底与下划线。
- 两行分别以“原文”“新文”加全文朗读。
## 事件
- 无。
## 键盘操作
- 无:纯展示。
## 限制
- 只比较一行;多行请用 DiffViewer。
+53
View File
@@ -0,0 +1,53 @@
# 303 ThreeWayMerge 三方合并
## 用途
以本地、基准、远端三栏和结果栏显示三方合并,逐块采纳本地、远端、两者或基准。
## 最小示例
```go
var state mujica.ThreeWayMergeState
base, local, remote := "a\nb\nc", "a\nB1\nc", "a\nB2\nc"
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.ThreeWayMerge(c, &state, base, local, remote, mujica.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 ThreeWayMerge(c *ui.Context, s *ThreeWayMergeState, base, ours, theirs string, opts ThreeWayMergeOptions) ChoiceResult`
## 参数
`ThreeWayMergeOptions`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| Disabled | bool | false | 禁用采纳。 |
`ThreeWayMergeState.Result()` 返回合并文本,未解决的冲突保留冲突标记;`Resolved()` 返回已解决与总冲突数。分块只在三段文本改变时计算。给返回的 `Element` 一个高度。
## 状态
- 只有一侧修改或两侧相同修改的块自动合并,来源一侧以浅色底标出。
- 冲突块在结果栏显示 Danger 色“未解决”与警示图标。
- 已采纳的按钮为 Selection 底、AccentText 边框与对勾。
- 较长的未变更块只显示首尾各两行。
## 事件
- `Changed()`:某个冲突的选择改变。
## 键盘操作
- 列表可获得焦点并滚动;采纳按钮用 Tab 到达,Enter 或 Space 选择。
## 限制
- 按行合并,不做词级合并。
- 相邻(中间没有未变更行)的两侧修改视为冲突。
+51
View File
@@ -0,0 +1,51 @@
# 304 ConflictResolver 冲突解决
## 用途
读取带 git 冲突标记的文本,列出冲突块,逐块采纳当前、传入或两者,并显示已解决计数。
## 最小示例
```go
var state mujica.ConflictResolverState
text := "a\n<<<<<<< HEAD\nmine\n=======\ntheirs\n>>>>>>> feature\nb"
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ConflictResolver(c, &state, text, mujica.ConflictResolverOptions{}).Changed() {
_ = state.Result() // save the file once every block is resolved
}
}
ui.Render(view, 400, 300, 1)
```
签名:`func ConflictResolver(c *ui.Context, s *ConflictResolverState, text string, opts ConflictResolverOptions) ChoiceResult`
## 参数
`ConflictResolverOptions`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| Disabled | bool | false | 禁用采纳。 |
`ConflictResolverState.Result()` 返回替换后的文本,未解决的块保留原标记;`Resolved()` 返回已解决与总数。只在文本改变时重新解析。
## 状态
- 顶部显示“已解决 n / m 个冲突”与 Success 色进度条。
- 未解决块标题为警示图标与“未解决”,已解决为对勾与“已解决”。
- 被采纳的一侧显示浅色底与对勾,按钮带对勾;已解决块提供“撤销”。
- 标记未闭合时显示 Danger 色提示与起始行号,不列出冲突。
## 事件
- `Changed()`:某块的选择改变。
## 键盘操作
- Tab 依次经过各块按钮,Enter 或 Space 采纳。
## 限制
- 支持 diff3 样式的 ||||||| 基准段,但不显示它。
- 冲突块不虚拟化;适合单个文件的冲突数量。
+66
View File
@@ -0,0 +1,66 @@
# 305 ChangesList 更改列表
## 用途
把工作区更改分为“已暂存”与“更改”两组,逐文件或整组暂存、取消暂存,并选择文件查看差异。只构建可见行。
## 最小示例
```go
var state mujica.ChangesListState
files := []mujica.ChangedFile{
{Path: "main.go", Status: mujica.GitModified, Staged: true},
{Path: "notes.txt", Status: mujica.GitUntracked},
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.ChangesList(c, &state, files, mujica.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 ChangesList(c *ui.Context, s *ChangesListState, files []ChangedFile, opts ChangesListOptions) GitListResult`
## 参数
`ChangedFile`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| Path | string | 必填 | 文件路径;为空时 panic。 |
| Status | GitStatus | GitModified | 状态,见 GitStatusBadge;未知值 panic。 |
| Staged | bool | false | 放入已暂存组。 |
`ChangesListOptions`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| Disabled | bool | false | 禁用:不暂存、不改变选择。 |
`ChangesListState` 由调用方持有,`Selected()` 返回所选文件在 `files` 中的下标,无则 -1。`files` 换成新切片或长度变化时重新分组,分组不在每帧计算。给返回的 `Element` 一个高度。
## 状态
- 组标题为衬线小字与计数,空组的整组按钮禁用。
- 每行显示 GitStatusBadge(字母、边框与颜色)、文件名与目录。
- 选中行:Selection 背景加左侧 AccentText 竖线。
- 悬停与按下:图标按钮 SurfaceHover / Selection 背景。
## 事件
- `Action()` 返回 `"stage"`、`"unstage"`(附路径)或 `"stage_all"`、`"unstage_all"`。
- `Changed()` 选择变化;`Submitted()` 双击或 Enter。
## 键盘操作
- Tab 进入列表;↑/↓、Home/End 移动选择,跳过组标题;输入文件名开头字母跳转;Enter 提交。
- 行内暂存按钮可用 Tab 到达,Enter 或 Space 执行。
## 限制
- 不读取仓库:文件列表与暂存操作都由调用方完成。
- 重命名的旧路径不单独显示。
+62
View File
@@ -0,0 +1,62 @@
# 306 CommitInput 提交输入
## 用途
编辑提交标题与正文,标题长度提示,提交与修订(amend),并防止重复提交。
## 最小示例
```go
msg := mujica.CommitMessage{}
busy := false
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.CommitInput(c, &msg, mujica.CommitInputOptions{Busy: busy})
if r.Committed() || r.Amended() {
busy = true // run git commit, then clear busy
}
}
ui.Render(view, 400, 300, 1)
```
签名:`func CommitInput(c *ui.Context, m *CommitMessage, opts CommitInputOptions) CommitInputResult`
## 参数
`CommitMessage`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| Title | string | "" | 提交标题;为空时提交与修订禁用。 |
| Body | string | "" | 提交正文。 |
`CommitInputOptions`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| TitleLimit | int | 0(即 50) | 建议的标题长度;为负 panic。 |
| Busy | bool | false | 提交进行中:提交按钮显示加载,输入只读。 |
| Disabled | bool | false | 整体禁用。 |
返回 `CommitInputResult`,含 `Element`。
## 状态
- 标题右侧显示“已用/上限”,超出时变为 Warning 色并朗读超出字数。
- 提交后进入待定状态:按钮与输入锁定,直到调用方把 Busy 设为 true 再恢复 false。
- Busy 时提交按钮保留尺寸并显示加载。
## 事件
- `Committed()`:点击提交、标题内 Enter 或正文内 Cmd+Enter。
- `Amended()`:点击修订。
## 键盘操作
- Tab 依次经过标题、正文、修订、提交。
- 标题中 Enter、正文中 Cmd+Enter(非 macOS 为 Ctrl+Enter)提交。
## 限制
- 防重入依赖调用方在提交期间设置 Busy;从未设置 Busy 时组件保持锁定。
- 不做提交信息格式校验。
+67
View File
@@ -0,0 +1,67 @@
# 307 CommitGraph 提交图谱
## 用途
按从新到旧的顺序显示提交,左侧绘制泳道、分支与合并线;只构建可见行,可承载十万级提交。
## 最小示例
```go
var state mujica.CommitGraphState
commits := []mujica.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) {
mujica.Use(c, mujica.Settings{})
mujica.CommitGraph(c, &state, commits, mujica.CommitGraphOptions{}).Element.Height(240)
}
ui.Render(view, 400, 300, 1)
```
签名:`func CommitGraph(c *ui.Context, s *CommitGraphState, commits []Commit, opts CommitGraphOptions) GitListResult`
## 参数
`Commit`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| Hash | string | 必填 | 提交哈希;为空时 panic。 |
| Parents | []string | nil | 父提交;两个以上为合并提交。 |
| Author | string | "" | 作者。 |
| When | time.Time | 零值 | 提交时间。 |
| Subject | string | "" | 提交标题。 |
| Refs | []string | nil | 指向该提交的分支与标签。 |
`CommitGraphOptions`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| TimeFormat | string | "2006-01-02 15:04" | 时间格式。 |
| MaxLanes | int | 0(即 8) | 图谱最多显示的泳道数,超出部分裁剪;为负 panic。 |
| Status | DataStatus | DataReady | 加载、追加、失败状态。 |
| Error | string | "" | 失败说明。 |
`CommitGraphState.List` 保存选择(`Selected()` 返回哈希)。泳道布局在提交切片或长度变化时计算一次,不在每帧计算。提交须按子提交在前的拓扑顺序给出。给返回的 `Element` 一个高度。
## 状态
- 普通提交为实心圆点,合并提交为空心菱形,形状区分而不只靠颜色。
- 泳道颜色取自 AccentText、Info、Success、Warning、Danger、TextMuted,按位置区分。
- 选中行:Selection 背景与对勾。
## 事件
- `Changed()` 选择变化;`Submitted()` 双击或 Enter 打开提交。
## 键盘操作
- Tab 进入列表;↑/↓、Home/End 移动选择;输入标题开头字母跳转;Enter 打开。
## 限制
- 原地修改提交切片而不改变其地址与长度时,布局不会重算;请传入新切片。
- 泳道数超过 MaxLanes 时,右侧泳道被裁剪。
+53
View File
@@ -0,0 +1,53 @@
# 308 CommitList 提交列表
## 用途
显示提交列表:标题、引用、作者、短哈希与时间,选择后查看。只构建可见行。
## 最小示例
```go
var state mujica.ListState[string]
commits := []mujica.Commit{{Hash: "a1b2c3d4", Author: "Sakiko", When: time.Now(), Subject: "Add the commit list"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.CommitList(c, &state, commits, mujica.CommitListOptions{})
r.Element.Height(240)
if hash, ok := state.Selected(); ok && r.Submitted() {
_ = hash // show the commit
}
}
ui.Render(view, 400, 300, 1)
```
签名:`func CommitList(c *ui.Context, s *ListState[string], commits []Commit, opts CommitListOptions) GitListResult`
## 参数
`CommitListOptions`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| TimeFormat | string | "2006-01-02 15:04" | 时间格式。 |
| Status | DataStatus | DataReady | 加载、追加、失败状态。 |
| Error | string | "" | 失败说明。 |
`Commit` 见 CommitGraph。选择保存在 `ListState`,以哈希为键。给返回的 `Element` 一个高度。
## 状态
- 引用以 AccentText 描边小标签显示;短哈希为等宽 AccentText。
- 空列表显示“没有提交”;加载与失败使用列表的统一占位。
- 选中行:Selection 背景与对勾。
## 事件
- `Changed()` 选择变化;`Submitted()` 双击或 Enter。
## 键盘操作
- Tab 进入列表;↑/↓、Home/End 移动选择;输入标题开头字母跳转;Enter 打开。
## 限制
- 时间按调用方给定格式显示,不做相对时间。
+64
View File
@@ -0,0 +1,64 @@
# 309 BranchSelector 分支选择器
## 用途
按钮显示当前分支,点击弹出可搜索的本地与远端分支列表;可选“新建分支”入口。
## 最小示例
```go
branch := "main"
branches := []mujica.Branch{{Name: "main", Current: true}, {Name: "origin/main", Remote: true}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.BranchSelector(c, &branch, branches, mujica.BranchSelectorOptions{AllowCreate: true})
if name, ok := r.Created(); ok {
_ = name // run git switch -c name
}
}
ui.Render(view, 400, 300, 1)
```
签名:`func BranchSelector(c *ui.Context, selected *string, branches []Branch, opts BranchSelectorOptions) BranchSelectorResult`
## 参数
`Branch`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| Name | string | 必填 | 唯一名称,远端分支带远端名,如 origin/main;为空时 panic。 |
| Remote | bool | false | 归入远端组。 |
| Current | bool | false | 当前检出的分支。 |
| Upstream | string | "" | 上游分支。 |
| Ahead, Behind | int | 0 | 领先、落后的提交数;为负 panic。 |
`BranchSelectorOptions`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| AllowCreate | bool | false | 搜索词不是已有分支名时,列表末尾提供“新建分支”。 |
| Disabled | bool | false | 禁用;打开中的弹层会关闭。 |
返回 `BranchSelectorResult`,含 `Element`。过滤结果在搜索词或分支切片变化时才重新计算。
## 状态
- 弹层使用统一浮层外观,160 ms 淡入,尺寸限制在窗口内,内容滚动。
- 本地、远端分组标题为衬线小字;当前分支加粗并带对勾。
- 高亮项:Selection 背景,键盘移动时滚入视野。
## 事件
- `Changed()`:选择了另一个分支,`*selected` 已更新。
- `Created()`:用户要求新建的分支名(已去除首尾空白)。
## 键盘操作
- Enter 或 Space 打开;焦点进入搜索框。
- ↑/↓ 移动高亮并循环,Enter 选择,Escape 关闭并把焦点还给按钮。
## 限制
- 不执行 git:选择与新建都由调用方处理。
- 搜索为大小写不敏感的子串匹配。
+62
View File
@@ -0,0 +1,62 @@
# 310 BranchList 分支列表
## 用途
分支列表:标出当前分支,显示上游与领先、落后数,每行提供调用方定义的操作按钮。
## 最小示例
```go
var state mujica.ListState[string]
branches := []mujica.Branch{{Name: "main", Current: true, Upstream: "origin/main", Ahead: 2}}
actions := []mujica.GitAction{{ID: "delete", Label: "Delete", Icon: "trash-2"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.BranchList(c, &state, branches, mujica.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 BranchList(c *ui.Context, s *ListState[string], branches []Branch, opts BranchListOptions) GitListResult`
## 参数
`BranchListOptions`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| Actions | []GitAction | nil | 每行的图标操作;ID、Label、Icon 必填且 ID 唯一,否则 panic。 |
| Status | DataStatus | DataReady | 加载、追加、失败状态。 |
| Error | string | "" | 失败说明。 |
`GitAction`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| ID | string | 必填 | `Action()` 返回的标识。 |
| Label | string | 必填 | 按钮名称与提示。 |
| Icon | string | 必填 | icons/ 中的图标名。 |
`Branch` 见 BranchSelector。选择以分支名为键。给返回的 `Element` 一个高度。
## 状态
- 当前分支:对勾、加粗与“当前”文字。
- 领先 ↑n、落后 ↓n 以箭头与数字显示,朗读完整说明。
## 事件
- `Action()` 返回操作 ID 与分支名。
- `Changed()`、`Submitted()` 同 CommitList。
## 键盘操作
- ↑/↓、Home/End 移动选择;行内操作按钮用 Tab 到达,Enter 或 Space 执行。
## 限制
- 操作由调用方执行;组件不确认危险操作,需要时在回调里使用 Popconfirm 等组件。
+59
View File
@@ -0,0 +1,59 @@
# 311 TagList 标签列表
## 用途
标签列表:名称、注释与目标提交,每行可跳转到提交。
## 最小示例
```go
var state mujica.ListState[string]
tags := []mujica.GitTag{{Name: "v1.1.0", Target: "a1b2c3d4", Annotation: "Release"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.TagList(c, &state, tags, mujica.TagListOptions{})
r.Element.Height(200)
if _, hash, ok := r.Action(); ok {
_ = hash // show the tagged commit
}
}
ui.Render(view, 400, 300, 1)
```
签名:`func TagList(c *ui.Context, s *ListState[string], tags []GitTag, opts TagListOptions) GitListResult`
## 参数
`GitTag`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| Name | string | 必填 | 标签名,唯一。 |
| Target | string | 必填 | 目标提交哈希。 |
| Annotation | string | "" | 注释;轻量标签留空。 |
`TagListOptions`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| Status | DataStatus | DataReady | 加载、追加、失败状态。 |
| Error | string | "" | 失败说明。 |
Name 或 Target 为空时 panic。给返回的 `Element` 一个高度。
## 状态
- 注释为 TextMuted 单行,超长省略。
## 事件
- `Action()` 返回 `"jump"` 与目标哈希。
- `Changed()`、`Submitted()` 同 CommitList。
## 键盘操作
- ↑/↓、Home/End 移动选择;“跳转到提交”按钮用 Tab 到达。
## 限制
- 不读取标签:数据由调用方提供。
+58
View File
@@ -0,0 +1,58 @@
# 312 StashList 贮藏列表
## 用途
贮藏列表,每行提供应用、弹出与删除。
## 最小示例
```go
var state mujica.ListState[string]
stashes := []mujica.Stash{{Ref: "stash@{0}", Message: "WIP on main", Branch: "main"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.StashList(c, &state, stashes, mujica.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 StashList(c *ui.Context, s *ListState[string], stashes []Stash, opts StashListOptions) GitListResult`
## 参数
`Stash`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| Ref | string | 必填 | 如 stash@{0};为空时 panic。 |
| Message | string | "" | 贮藏说明。 |
| Branch | string | "" | 创建时所在分支。 |
`StashListOptions`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| Status | DataStatus | DataReady | 加载、追加、失败状态。 |
| Error | string | "" | 失败说明。 |
给返回的 `Element` 一个高度。
## 状态
- Ref 为等宽 AccentText;三个操作为带名称与提示的图标按钮。
## 事件
- `Action()` 返回 `"apply"`、`"pop"` 或 `"drop"` 与 Ref。
## 键盘操作
- ↑/↓ 移动选择;操作按钮用 Tab 到达,Enter 或 Space 执行。
## 限制
- 删除不带确认;需要时由调用方在回调里确认。
+62
View File
@@ -0,0 +1,62 @@
# 313 BlameView 逐行追溯
## 用途
代码逐行显示最后修改的作者与提交;同一提交的连续行合并显示,悬停显示提交详情。只构建可见行。
## 最小示例
```go
var state mujica.BlameViewState
lines := []mujica.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) {
mujica.Use(c, mujica.Settings{})
mujica.BlameView(c, &state, lines, mujica.BlameViewOptions{Language: "go"}).Height(240)
}
ui.Render(view, 400, 300, 1)
```
签名:`func BlameView(c *ui.Context, s *BlameViewState, lines []BlameLine, opts BlameViewOptions) *ui.Element`
## 参数
`BlameLine`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| Hash | string | 必填 | 提交哈希;为空时 panic。 |
| Author | string | "" | 作者。 |
| When | time.Time | 零值 | 提交时间。 |
| Summary | string | "" | 提交标题,悬停时显示。 |
| Text | string | "" | 该行代码。 |
`BlameViewOptions`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| Language | string | "" | 语法高亮语言,如 go;空为纯文本。 |
| TimeFormat | string | "2006-01-02" | 日期格式。 |
`BlameViewState` 由调用方持有;块注释状态在数据或语言变化时计算一次。给返回元素一个高度。
## 状态
- 每段同提交行只在首行显示短哈希、作者与日期,段之间有细分隔线。
- 左栏悬停为 SurfaceHover,600 ms 后显示提示:完整哈希、作者、日期与标题。
- 代码使用统一的语法颜色,注释为斜体。
## 事件
- 无。
## 键盘操作
- 列表可获得焦点并滚动;输入行首文字跳转到该行。
## 限制
- 长行不换行,超出部分裁剪。
- 悬停详情使用 MyGo 提示,只能用指针触发;每行左栏的名称同样朗读作者与提交。
+53
View File
@@ -0,0 +1,53 @@
# 314 FileHistory 文件历史
## 用途
单个文件的提交历史,每条提交提供“查看差异”入口。
## 最小示例
```go
var state mujica.ListState[string]
commits := []mujica.Commit{{Hash: "a1b2c3d4", Author: "Sakiko", When: time.Now(), Subject: "Edit main.go"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.FileHistory(c, &state, "main.go", commits, mujica.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 FileHistory(c *ui.Context, s *ListState[string], path string, commits []Commit, opts FileHistoryOptions) GitListResult`
## 参数
`FileHistoryOptions`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| TimeFormat | string | "2006-01-02 15:04" | 时间格式。 |
| Status | DataStatus | DataReady | 加载、追加、失败状态。 |
| Error | string | "" | 失败说明。 |
path 为空时 panic。给返回的 `Element` 一个高度,列表填满其余空间。
## 状态
- 标题行显示历史图标、等宽路径与“历史”。
- 空历史显示“此文件没有历史”。
## 事件
- `Action()` 返回 `"diff"` 与提交哈希。
- `Changed()`、`Submitted()` 同 CommitList。
## 键盘操作
- ↑/↓、Home/End 移动选择;“查看差异”按钮用 Tab 到达。
## 限制
- 不跟踪重命名:提交列表由调用方提供。
+62
View File
@@ -0,0 +1,62 @@
# 315 PullRequestCard 拉取请求卡片
## 用途
概览一个拉取请求:标题、作者、状态、检查、评审人与差异统计。
## 最小示例
```go
pr := mujica.PullRequest{Number: 42, Title: "Add git components", Author: "Sakiko", Head: "feature", Base: "main",
ChecksPassed: 3, Reviewers: []mujica.Reviewer{{Name: "Uika", State: mujica.ReviewApproved}}, Added: 120, Deleted: 8, Files: 6}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.PullRequestCard(c, pr, mujica.PullRequestCardOptions{}).Changed() {
// open the pull request
}
}
ui.Render(view, 400, 300, 1)
```
签名:`func PullRequestCard(c *ui.Context, pr PullRequest, opts PullRequestCardOptions) ChoiceResult`
## 参数
`PullRequest`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| Number | int | 必填 | 编号,须为正。 |
| Title | string | 必填 | 标题。 |
| Author | string | "" | 作者。 |
| State | PullRequestState | PullRequestOpen | Open、Draft、Merged、Closed;未知值 panic。 |
| Head, Base | string | "" | 源分支与目标分支,都有值时显示。 |
| ChecksPassed, ChecksFailed, ChecksPending | int | 0 | 检查计数;为负 panic。 |
| Reviewers | []Reviewer | nil | 评审人与状态(Pending、Approved、ChangesRequested)。 |
| Added, Deleted, Files | int | 0 | 差异统计。 |
`PullRequestCardOptions`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| Disabled | bool | false | 禁用打开。 |
返回 `ChoiceResult`。
## 状态
- 状态徽章同时使用图标、文字与描边颜色。
- 检查以图标加数字显示,朗读完整说明;为 0 的类别不显示。
- 评审人以头像加角标图标表示状态,名称与状态写入提示和朗读文本。
## 事件
- `Changed()`:点击标题或在标题上按 Enter,表示打开。
## 键盘操作
- Tab 到标题链接,Enter 或 Space 打开。
## 限制
- 数据由调用方获取;组件不刷新检查状态。
+66
View File
@@ -0,0 +1,66 @@
# 316 ReviewComment 评审评论
## 用途
行级评审讨论:显示文件与行号、评论列表,支持回复、解决与重新打开。
## 最小示例
```go
thread := mujica.ReviewThread{File: "main.go", Line: 12, Messages: []mujica.ReviewMessage{
{Author: "Uika", When: time.Now(), Body: "Can this return early?"},
}}
draft := ""
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.ReviewComment(c, thread, &draft, mujica.ReviewCommentOptions{})
if text, ok := r.Replied(); ok {
thread.Messages = append(thread.Messages, mujica.ReviewMessage{Author: "Sakiko", When: time.Now(), Body: text})
}
if r.Resolved() || r.Reopened() {
thread.Resolved = !thread.Resolved
}
}
ui.Render(view, 400, 300, 1)
```
签名:`func ReviewComment(c *ui.Context, thread ReviewThread, draft *string, opts ReviewCommentOptions) ReviewCommentResult`
## 参数
`ReviewThread`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| File | string | 必填 | 文件路径。 |
| Line | int | 必填 | 行号,须为正。 |
| Resolved | bool | false | 已解决:隐藏回复框,显示“重新打开”。 |
| Messages | []ReviewMessage | 必填 | 至少一条评论(作者、时间、正文)。 |
`ReviewCommentOptions`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| TimeFormat | string | "2006-01-02 15:04" | 时间格式。 |
| Disabled | bool | false | 禁用回复、解决与重新打开。 |
`draft` 保存回复草稿,发送后清空。
## 状态
- 已解决:标题栏显示 Success 色图标与“已解决”。
- 回复按钮在草稿为空时禁用。
## 事件
- `Replied()`:发送的回复文本(去除首尾空白)。
- `Resolved()`、`Reopened()`:对应按钮。
## 键盘操作
- Tab 经过回复框与按钮;回复框内 Cmd+Enter(非 macOS 为 Ctrl+Enter)发送。
## 限制
- 评论正文为纯文本,不渲染 Markdown。
- 线程状态由调用方更新。
+45
View File
@@ -0,0 +1,45 @@
# 317 DiffStat 差异统计
## 用途
显示 +新增 −删除 行数,以及按比例填充的方块条。
## 最小示例
```go
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.DiffStat(c, 120, 8, mujica.DiffStatOptions{})
}
ui.Render(view, 400, 300, 1)
```
签名:`func DiffStat(c *ui.Context, added, deleted int, opts DiffStatOptions) *ui.Element`
## 参数
`DiffStatOptions`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| Blocks | int | 0(即 5) | 比例条方块数;为负 panic。 |
added、deleted 为负时 panic。
## 状态
- 数字带 + 与 − 符号,不只靠颜色;非零一侧至少占一格。
- 无变化时方块为 Border 色。
- 朗读为“新增 n 行,删除 m 行”。
## 事件
- 无。
## 键盘操作
- 无:纯展示,不获取焦点。
## 限制
- 无。
+44
View File
@@ -0,0 +1,44 @@
# 318 GitStatusBadge Git 状态标记
## 用途
以字母 M、A、D、U、R、C 加描边与颜色标记文件状态。
## 最小示例
```go
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.GitStatusBadge(c, mujica.GitModified, mujica.GitStatusBadgeOptions{ShowLabel: true})
}
ui.Render(view, 400, 300, 1)
```
签名:`func GitStatusBadge(c *ui.Context, status GitStatus, opts GitStatusBadgeOptions) *ui.Element`
## 参数
`GitStatusBadgeOptions`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| ShowLabel | bool | false | 在字母旁写出状态名称;否则作为提示。 |
`GitStatus`:GitModified、GitAdded、GitDeleted、GitUntracked、GitRenamed、GitCopied;`Letter()` 返回字母。未知值 panic。
## 状态
- 字母为等宽粗体,颜色依状态:修改 Warning、添加 Success、删除 Danger、未跟踪与重命名 Info、复制 AccentText。
- 名称写入朗读文本与提示,不只靠颜色。
## 事件
- 无。
## 键盘操作
- 无:纯展示。
## 限制
- 无。
+62
View File
@@ -0,0 +1,62 @@
# 319 CommandBlock 命令块
## 用途
把一次命令执行显示为一块:提示符与高亮的命令、退出码、耗时与可折叠的 ANSI 输出,可复制命令或输出。
## 最小示例
```go
open := true
run := mujica.CommandRun{Command: "go test ./...", Output: "\x1b[32mok\x1b[0m example 0.4s", Duration: 400 * time.Millisecond}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.CommandBlock(c, run, &open, mujica.CommandBlockOptions{})
if what, ok := r.Copied(); ok {
_ = what // "command" or "output" is on the clipboard
}
}
ui.Render(view, 400, 300, 1)
```
签名:`func CommandBlock(c *ui.Context, run CommandRun, open *bool, opts CommandBlockOptions) CommandBlockResult`
## 参数
`CommandRun`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| Command | string | 必填 | 命令;为空时 panic。 |
| Output | string | "" | 输出,可含 ANSI 转义。 |
| ExitCode | int | 0 | 退出码。 |
| Duration | time.Duration | 0 | 耗时;为负 panic。 |
| Running | bool | false | 运行中:显示加载标记,不显示退出码与耗时。 |
`CommandBlockOptions`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| Prompt | string | "$" | 命令前的提示符。 |
| MaxOutputHeight | float32 | 0(即 320) | 输出区最大高度,超出滚动;为负 panic。 |
`open` 控制输出是否展开。
## 状态
- 成功:Success 色对勾与“退出码 0”;失败:Danger 色叉号与退出码。
- 展开箭头 120 ms 转动,减少动画时直接切换。
- 输出区为 Background 底色,等宽字体,横纵滚动。
## 事件
- `Toggled()`:展开或折叠。
- `Copied()`:`"command"` 或 `"output"`;复制的输出已去除 ANSI 转义。
## 键盘操作
- Tab 到标题行,Enter 或 Space 展开或折叠;复制按钮用 Tab 到达。
## 限制
- 不运行命令:输出与状态由调用方提供。
+45
View File
@@ -0,0 +1,45 @@
# 320 AnsiText ANSI 文本
## 用途
渲染终端输出:16 色、256 色、真彩,以及粗体、斜体、下划线、删除线、暗淡与反显。
## 最小示例
```go
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.AnsiText(c, "\x1b[1;32mPASS\x1b[0m 12 tests\n\x1b[31mFAIL\x1b[0m 1 test", mujica.AnsiTextOptions{})
}
ui.Render(view, 400, 300, 1)
```
签名:`func AnsiText(c *ui.Context, text string, opts AnsiTextOptions) *ui.Element`
## 参数
`AnsiTextOptions`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| Wrap | bool | false | 按宽度换行;否则每行保持完整。 |
样式跨行延续。
## 状态
- 16 色映射为在两种主题 Surface 上都达到 4.5:1 的颜色;256 色与真彩按原值显示。
- 默认前景为 Text;反显以 Surface 为前景。
## 事件
- 无。
## 键盘操作
- 无:纯展示。
## 限制
- 只解释 SGR(m 结尾)序列;光标移动、清屏等其他 CSI 序列原样保留为文字,并以 slog.Debug 记录。
- 256 色与真彩不做对比度调整。
+263
View File
@@ -0,0 +1,263 @@
package mujica_test
import (
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
func ExampleDiffViewer() {
var state mujica.DiffViewerState
from := "func main() {\n\tprintln(\"hi\")\n}\n"
to := "func main() {\n\tprintln(\"hello\")\n}\n"
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.DiffViewer(c, &state, from, to, mujica.DiffViewerOptions{Language: "go", SideBySide: true}).Height(240)
}
ui.Render(view, 400, 300, 1)
}
func ExampleInlineDiff() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.InlineDiff(c, "The court meets at noon", "The court meets at dusk", mujica.InlineDiffOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleThreeWayMerge() {
var state mujica.ThreeWayMergeState
base, local, remote := "a\nb\nc", "a\nB1\nc", "a\nB2\nc"
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.ThreeWayMerge(c, &state, base, local, remote, mujica.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 mujica.ConflictResolverState
text := "a\n<<<<<<< HEAD\nmine\n=======\ntheirs\n>>>>>>> feature\nb"
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ConflictResolver(c, &state, text, mujica.ConflictResolverOptions{}).Changed() {
_ = state.Result() // save the file once every block is resolved
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleChangesList() {
var state mujica.ChangesListState
files := []mujica.ChangedFile{
{Path: "main.go", Status: mujica.GitModified, Staged: true},
{Path: "notes.txt", Status: mujica.GitUntracked},
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.ChangesList(c, &state, files, mujica.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 := mujica.CommitMessage{}
busy := false
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.CommitInput(c, &msg, mujica.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 mujica.CommitGraphState
commits := []mujica.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) {
mujica.Use(c, mujica.Settings{})
mujica.CommitGraph(c, &state, commits, mujica.CommitGraphOptions{}).Element.Height(240)
}
ui.Render(view, 400, 300, 1)
}
func ExampleCommitList() {
var state mujica.ListState[string]
commits := []mujica.Commit{{Hash: "a1b2c3d4", Author: "Sakiko", When: time.Now(), Subject: "Add the commit list"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.CommitList(c, &state, commits, mujica.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 := []mujica.Branch{{Name: "main", Current: true}, {Name: "origin/main", Remote: true}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.BranchSelector(c, &branch, branches, mujica.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 mujica.ListState[string]
branches := []mujica.Branch{{Name: "main", Current: true, Upstream: "origin/main", Ahead: 2}}
actions := []mujica.GitAction{{ID: "delete", Label: "Delete", Icon: "trash-2"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.BranchList(c, &state, branches, mujica.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 mujica.ListState[string]
tags := []mujica.GitTag{{Name: "v1.1.0", Target: "a1b2c3d4", Annotation: "Release"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.TagList(c, &state, tags, mujica.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 mujica.ListState[string]
stashes := []mujica.Stash{{Ref: "stash@{0}", Message: "WIP on main", Branch: "main"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.StashList(c, &state, stashes, mujica.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 mujica.BlameViewState
lines := []mujica.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) {
mujica.Use(c, mujica.Settings{})
mujica.BlameView(c, &state, lines, mujica.BlameViewOptions{Language: "go"}).Height(240)
}
ui.Render(view, 400, 300, 1)
}
func ExampleFileHistory() {
var state mujica.ListState[string]
commits := []mujica.Commit{{Hash: "a1b2c3d4", Author: "Sakiko", When: time.Now(), Subject: "Edit main.go"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.FileHistory(c, &state, "main.go", commits, mujica.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 := mujica.PullRequest{Number: 42, Title: "Add git components", Author: "Sakiko", Head: "feature", Base: "main",
ChecksPassed: 3, Reviewers: []mujica.Reviewer{{Name: "Uika", State: mujica.ReviewApproved}}, Added: 120, Deleted: 8, Files: 6}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.PullRequestCard(c, pr, mujica.PullRequestCardOptions{}).Changed() {
// open the pull request
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleReviewComment() {
thread := mujica.ReviewThread{File: "main.go", Line: 12, Messages: []mujica.ReviewMessage{
{Author: "Uika", When: time.Now(), Body: "Can this return early?"},
}}
draft := ""
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.ReviewComment(c, thread, &draft, mujica.ReviewCommentOptions{})
if text, ok := r.Replied(); ok {
thread.Messages = append(thread.Messages, mujica.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) {
mujica.Use(c, mujica.Settings{})
mujica.DiffStat(c, 120, 8, mujica.DiffStatOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleGitStatusBadge() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.GitStatusBadge(c, mujica.GitModified, mujica.GitStatusBadgeOptions{ShowLabel: true})
}
ui.Render(view, 400, 300, 1)
}
func ExampleCommandBlock() {
open := true
run := mujica.CommandRun{Command: "go test ./...", Output: "\x1b[32mok\x1b[0m example 0.4s", Duration: 400 * time.Millisecond}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.CommandBlock(c, run, &open, mujica.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) {
mujica.Use(c, mujica.Settings{})
mujica.AnsiText(c, "\x1b[1;32mPASS\x1b[0m 12 tests\n\x1b[31mFAIL\x1b[0m 1 test", mujica.AnsiTextOptions{})
}
ui.Render(view, 400, 300, 1)
}
+296 -1
View File
@@ -1,3 +1,298 @@
package main
var gitPages = []page{}
var gitPages = []page{
{
ID: "301", Name: "DiffViewer", ZhName: "差异查看器 比较 diff", Category: "Git",
Demo: gitDiffViewerDemo,
Code: `var state mujica.DiffViewerState
from := "func main() {\n\tprintln(\"hi\")\n}\n"
to := "func main() {\n\tprintln(\"hello\")\n}\n"
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.DiffViewer(c, &state, from, to, mujica.DiffViewerOptions{Language: "go", SideBySide: true}).Height(240)
}
ui.Render(view, 400, 300, 1)`,
Keys: "Tab focuses the list; arrows and Page Up/Down scroll. Fold buttons take Tab; Enter or Space shows the hidden lines.",
},
{
ID: "302", Name: "InlineDiff", ZhName: "行内差异 词级差异", Category: "Git",
Demo: gitInlineDiffDemo,
Code: `view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.InlineDiff(c, "The court meets at noon", "The court meets at dusk", mujica.InlineDiffOptions{})
}
ui.Render(view, 400, 300, 1)`,
Keys: "Not focusable; each line is read with its full text.",
},
{
ID: "303", Name: "ThreeWayMerge", ZhName: "三方合并 合并冲突", Category: "Git",
Demo: gitThreeWayMergeDemo,
Code: `var state mujica.ThreeWayMergeState
base, local, remote := "a\nb\nc", "a\nB1\nc", "a\nB2\nc"
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.ThreeWayMerge(c, &state, base, local, remote, mujica.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)`,
Keys: "Tab reaches the take buttons of each conflict; Enter or Space chooses. The list scrolls with the arrows.",
},
{
ID: "304", Name: "ConflictResolver", ZhName: "冲突解决 冲突标记", Category: "Git",
Demo: gitConflictResolverDemo,
Code: `var state mujica.ConflictResolverState
text := "a\n<<<<<<< HEAD\nmine\n=======\ntheirs\n>>>>>>> feature\nb"
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ConflictResolver(c, &state, text, mujica.ConflictResolverOptions{}).Changed() {
_ = state.Result() // save the file once every block is resolved
}
}
ui.Render(view, 400, 300, 1)`,
Keys: "Tab moves through each block's buttons; Enter or Space accepts a side; Undo clears the choice.",
},
{
ID: "305", Name: "ChangesList", ZhName: "更改列表 暂存 未暂存", Category: "Git",
Demo: gitChangesListDemo,
Code: `var state mujica.ChangesListState
files := []mujica.ChangedFile{
{Path: "main.go", Status: mujica.GitModified, Staged: true},
{Path: "notes.txt", Status: mujica.GitUntracked},
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.ChangesList(c, &state, files, mujica.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)`,
Keys: "Up/Down choose a file, skipping group titles; typing jumps by name; stage buttons take Tab, Enter or Space.",
},
{
ID: "306", Name: "CommitInput", ZhName: "提交输入 提交信息", Category: "Git",
Demo: gitCommitInputDemo,
Code: `msg := mujica.CommitMessage{}
busy := false
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.CommitInput(c, &msg, mujica.CommitInputOptions{Busy: busy})
if r.Committed() || r.Amended() {
busy = true // run git commit, then clear busy
}
}
ui.Render(view, 400, 300, 1)`,
Keys: "Enter in the summary or Cmd/Ctrl+Enter in the description commits; Tab reaches Amend and Commit.",
},
{
ID: "307", Name: "CommitGraph", ZhName: "提交图谱 泳道 分支图", Category: "Git",
Demo: gitCommitGraphDemo,
Code: `var state mujica.CommitGraphState
commits := []mujica.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) {
mujica.Use(c, mujica.Settings{})
mujica.CommitGraph(c, &state, commits, mujica.CommitGraphOptions{}).Element.Height(240)
}
ui.Render(view, 400, 300, 1)`,
Keys: "Up/Down/Home/End choose a commit; typing jumps by subject; Enter opens it.",
},
{
ID: "308", Name: "CommitList", ZhName: "提交列表 提交历史", Category: "Git",
Demo: gitCommitListDemo,
Code: `var state mujica.ListState[string]
commits := []mujica.Commit{{Hash: "a1b2c3d4", Author: "Sakiko", When: time.Now(), Subject: "Add the commit list"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.CommitList(c, &state, commits, mujica.CommitListOptions{})
r.Element.Height(240)
if hash, ok := state.Selected(); ok && r.Submitted() {
_ = hash // show the commit
}
}
ui.Render(view, 400, 300, 1)`,
Keys: "Up/Down/Home/End choose a commit; typing jumps by subject; Enter opens it.",
},
{
ID: "309", Name: "BranchSelector", ZhName: "分支选择器 切换分支", Category: "Git",
Demo: gitBranchSelectorDemo,
Code: `branch := "main"
branches := []mujica.Branch{{Name: "main", Current: true}, {Name: "origin/main", Remote: true}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.BranchSelector(c, &branch, branches, mujica.BranchSelectorOptions{AllowCreate: true})
if name, ok := r.Created(); ok {
_ = name // run git switch -c name
}
}
ui.Render(view, 400, 300, 1)`,
Keys: "Enter or Space opens; type to filter; Up/Down move; Enter chooses or creates; Escape closes.",
},
{
ID: "310", Name: "BranchList", ZhName: "分支列表 上游 领先落后", Category: "Git",
Demo: gitBranchListDemo,
Code: `var state mujica.ListState[string]
branches := []mujica.Branch{{Name: "main", Current: true, Upstream: "origin/main", Ahead: 2}}
actions := []mujica.GitAction{{ID: "delete", Label: "Delete", Icon: "trash-2"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.BranchList(c, &state, branches, mujica.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)`,
Keys: "Up/Down choose a branch; row actions take Tab, Enter or Space.",
},
{
ID: "311", Name: "TagList", ZhName: "标签列表 tag", Category: "Git",
Demo: gitTagListDemo,
Code: `var state mujica.ListState[string]
tags := []mujica.GitTag{{Name: "v1.1.0", Target: "a1b2c3d4", Annotation: "Release"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.TagList(c, &state, tags, mujica.TagListOptions{})
r.Element.Height(200)
if _, hash, ok := r.Action(); ok {
_ = hash // show the tagged commit
}
}
ui.Render(view, 400, 300, 1)`,
Keys: "Up/Down choose a tag; the jump button takes Tab, Enter or Space.",
},
{
ID: "312", Name: "StashList", ZhName: "贮藏列表 stash", Category: "Git",
Demo: gitStashListDemo,
Code: `var state mujica.ListState[string]
stashes := []mujica.Stash{{Ref: "stash@{0}", Message: "WIP on main", Branch: "main"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.StashList(c, &state, stashes, mujica.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)`,
Keys: "Up/Down choose a stash; Apply, Pop and Drop take Tab, Enter or Space.",
},
{
ID: "313", Name: "BlameView", ZhName: "逐行追溯 blame 作者", Category: "Git",
Demo: gitBlameViewDemo,
Code: `var state mujica.BlameViewState
lines := []mujica.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) {
mujica.Use(c, mujica.Settings{})
mujica.BlameView(c, &state, lines, mujica.BlameViewOptions{Language: "go"}).Height(240)
}
ui.Render(view, 400, 300, 1)`,
Keys: "Tab focuses the code; arrows scroll; typing jumps to a line starting with the text. Hover the left column for details.",
},
{
ID: "314", Name: "FileHistory", ZhName: "文件历史 单文件提交", Category: "Git",
Demo: gitFileHistoryDemo,
Code: `var state mujica.ListState[string]
commits := []mujica.Commit{{Hash: "a1b2c3d4", Author: "Sakiko", When: time.Now(), Subject: "Edit main.go"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.FileHistory(c, &state, "main.go", commits, mujica.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)`,
Keys: "Up/Down choose a commit; View changes takes Tab, Enter or Space.",
},
{
ID: "315", Name: "PullRequestCard", ZhName: "拉取请求卡片 合并请求 PR", Category: "Git",
Demo: gitPullRequestCardDemo,
Code: `pr := mujica.PullRequest{Number: 42, Title: "Add git components", Author: "Sakiko", Head: "feature", Base: "main",
ChecksPassed: 3, Reviewers: []mujica.Reviewer{{Name: "Uika", State: mujica.ReviewApproved}}, Added: 120, Deleted: 8, Files: 6}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.PullRequestCard(c, pr, mujica.PullRequestCardOptions{}).Changed() {
// open the pull request
}
}
ui.Render(view, 400, 300, 1)`,
Keys: "Tab reaches the title link; Enter or Space opens the pull request.",
},
{
ID: "316", Name: "ReviewComment", ZhName: "评审评论 代码评审", Category: "Git",
Demo: gitReviewCommentDemo,
Code: `thread := mujica.ReviewThread{File: "main.go", Line: 12, Messages: []mujica.ReviewMessage{
{Author: "Uika", When: time.Now(), Body: "Can this return early?"},
}}
draft := ""
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.ReviewComment(c, thread, &draft, mujica.ReviewCommentOptions{})
if text, ok := r.Replied(); ok {
thread.Messages = append(thread.Messages, mujica.ReviewMessage{Author: "Sakiko", When: time.Now(), Body: text})
}
if r.Resolved() || r.Reopened() {
thread.Resolved = !thread.Resolved
}
}
ui.Render(view, 400, 300, 1)`,
Keys: "Tab moves through the reply field and buttons; Cmd/Ctrl+Enter sends the reply.",
},
{
ID: "317", Name: "DiffStat", ZhName: "差异统计 增删行数", Category: "Git",
Demo: gitDiffStatDemo,
Code: `view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.DiffStat(c, 120, 8, mujica.DiffStatOptions{})
}
ui.Render(view, 400, 300, 1)`,
Keys: "Not focusable; read as additions and deletions.",
},
{
ID: "318", Name: "GitStatusBadge", ZhName: "Git 状态标记 文件状态", Category: "Git",
Demo: gitStatusBadgeDemo,
Code: `view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.GitStatusBadge(c, mujica.GitModified, mujica.GitStatusBadgeOptions{ShowLabel: true})
}
ui.Render(view, 400, 300, 1)`,
Keys: "Not focusable; each badge is read by its status name.",
},
{
ID: "319", Name: "CommandBlock", ZhName: "命令块 终端输出 退出码", Category: "Git",
Demo: gitCommandBlockDemo,
Code: `open := true
run := mujica.CommandRun{Command: "go test ./...", Output: "\x1b[32mok\x1b[0m example 0.4s", Duration: 400 * time.Millisecond}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.CommandBlock(c, run, &open, mujica.CommandBlockOptions{})
if what, ok := r.Copied(); ok {
_ = what // "command" or "output" is on the clipboard
}
}
ui.Render(view, 400, 300, 1)`,
Keys: "Tab reaches the header; Enter or Space folds the output; the copy buttons take Tab.",
},
{
ID: "320", Name: "AnsiText", ZhName: "ANSI 文本 终端颜色", Category: "Git",
Demo: gitAnsiTextDemo,
Code: `view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.AnsiText(c, "\x1b[1;32mPASS\x1b[0m 12 tests\n\x1b[31mFAIL\x1b[0m 1 test", mujica.AnsiTextOptions{})
}
ui.Render(view, 400, 300, 1)`,
Keys: "Not focusable; shows the text with its styles.",
},
}
+134
View File
@@ -0,0 +1,134 @@
package main
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
func gitPullRequestCardDemo(c *ui.Context, log func(string)) {
type state struct {
st int
failed, disabled bool
}
s := ui.Local(c.Root(), "315", func() state { return state{} })
navigationDemo(c, func() {
mujica.SegmentedControl(c, &s.st, []mujica.Segment{{Label: "Open"}, {Label: "Draft"}, {Label: "Merged"}, {Label: "Closed"}}, mujica.SegmentedControlOptions{Label: "State"})
navigationCheck(c, &s.failed, "Failing check", log)
navigationCheck(c, &s.disabled, "Disabled", log)
}, func() {
pr := mujica.PullRequest{Number: 318, Title: "Git components 301–320", Author: "Sakiko", State: mujica.PullRequestState(s.st),
Head: "feature/git", Base: "main", ChecksPassed: 12, ChecksPending: 1,
Reviewers: []mujica.Reviewer{{Name: "Uika", State: mujica.ReviewApproved}, {Name: "Mutsumi", State: mujica.ReviewChangesRequested}, {Name: "Umiri"}},
Added: 2412, Deleted: 310, Files: 46}
if s.failed {
pr.ChecksFailed = 1
}
if mujica.PullRequestCard(c, pr, mujica.PullRequestCardOptions{Disabled: s.disabled}).Changed() {
log(fmt.Sprintf("Open #%d", pr.Number))
}
})
}
func gitReviewCommentDemo(c *ui.Context, log func(string)) {
type state struct {
thread mujica.ReviewThread
draft string
disabled bool
}
s := ui.Local(c.Root(), "316", func() state {
return state{thread: mujica.ReviewThread{File: "commit_graph.go", Line: 88, Messages: []mujica.ReviewMessage{
{Author: "Uika", When: gitDemoTime, Body: "Should merge lanes reuse the parent's color?"},
{Author: "Sakiko", When: gitDemoTime.Add(time.Hour), Body: "Yes, the first parent keeps it."},
}}}
})
navigationDemo(c, func() {
navigationCheck(c, &s.disabled, "Disabled", log)
}, func() {
r := mujica.ReviewComment(c, s.thread, &s.draft, mujica.ReviewCommentOptions{Disabled: s.disabled})
if text, ok := r.Replied(); ok {
log("Reply: " + text)
s.thread.Messages = append(s.thread.Messages, mujica.ReviewMessage{Author: "You", When: c.Now(), Body: text})
}
if r.Resolved() {
log("Resolved")
s.thread.Resolved = true
}
if r.Reopened() {
log("Reopened")
s.thread.Resolved = false
}
})
}
func gitDiffStatDemo(c *ui.Context, log func(string)) {
type state struct{ added, deleted, blocks float64 }
s := ui.Local(c.Root(), "317", func() state { return state{added: 120, deleted: 30, blocks: 5} })
navigationDemo(c, func() {
navigationSlider(c, &s.added, "Added", 0, 500, log)
navigationSlider(c, &s.deleted, "Deleted", 0, 500, log)
navigationSlider(c, &s.blocks, "Blocks", 1, 10, log)
}, func() {
mujica.DiffStat(c, int(s.added), int(s.deleted), mujica.DiffStatOptions{Blocks: int(s.blocks)})
})
}
func gitStatusBadgeDemo(c *ui.Context, log func(string)) {
type state struct{ labels bool }
s := ui.Local(c.Root(), "318", func() state { return state{} })
navigationDemo(c, func() {
navigationCheck(c, &s.labels, "Show labels", log)
}, func() {
ui.Row(c).Gap(16).Wrap().Children(func() {
for st := mujica.GitModified; st <= mujica.GitCopied; st++ {
mujica.GitStatusBadge(c, st, mujica.GitStatusBadgeOptions{ShowLabel: s.labels})
}
})
})
}
func gitCommandBlockDemo(c *ui.Context, log func(string)) {
type state struct {
open, failed, running bool
}
s := ui.Local(c.Root(), "319", func() state { return state{open: true} })
navigationDemo(c, func() {
navigationCheck(c, &s.failed, "Failed", log)
navigationCheck(c, &s.running, "Running", log)
}, func() {
run := mujica.CommandRun{Command: "go test -run Git ./...", Duration: 3214 * time.Millisecond,
Output: "\x1b[32mok\x1b[0m \tmujicaui\t3.214s\n\x1b[33mwarn\x1b[0m slow test: TestCommitGraphVirtualized"}
if s.failed {
run.ExitCode = 1
run.Output = "\x1b[31m--- FAIL\x1b[0m: TestDiffViewer (0.02s)\n\x1b[1mFAIL\x1b[0m\tmujicaui\t0.401s"
}
run.Running = s.running
r := mujica.CommandBlock(c, run, &s.open, mujica.CommandBlockOptions{})
if r.Toggled() {
log(fmt.Sprintf("Toggled: open=%v", s.open))
}
if what, ok := r.Copied(); ok {
log("Copied " + what)
}
})
}
func gitAnsiTextDemo(c *ui.Context, log func(string)) {
type state struct{ wrap bool }
s := ui.Local(c.Root(), "320", func() state { return state{} })
navigationDemo(c, func() {
navigationCheck(c, &s.wrap, "Wrap", log)
}, func() {
ui.Column(c).Padding(12).Background(mujica.Tokens(c).Surface).Radius(4).Children(func() {
mujica.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[9mstruck\x1b[0m \x1b[7m inverse \x1b[0m\n"+
"A long line that only breaks when wrapping is on, so the terminal layout of columns stays intact as it was printed.\n"+
"Unknown escapes stay as text: \x1b[2K(cleared line)", mujica.AnsiTextOptions{Wrap: s.wrap})
})
})
}
+367
View File
@@ -0,0 +1,367 @@
package main
import (
"fmt"
"strings"
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
var gitDemoTime = time.Date(2026, 10, 5, 9, 30, 0, 0, time.UTC)
const gitDemoOld = "package court\n\nimport \"fmt\"\n\n// Greet welcomes a guest.\nfunc Greet(name string) {\n\tfmt.Println(\"Welcome,\", name)\n}\n\nfunc Leave() {}\n\nfunc Dance() {}\n\nfunc Bow() {}\n"
const gitDemoNew = "package court\n\nimport \"fmt\"\n\n// Greet welcomes a guest by title.\nfunc Greet(title, name string) {\n\tfmt.Println(\"Welcome,\", title, name)\n}\n\nfunc Leave() {}\n\nfunc Dance() {}\n\nfunc Bow() {}\n\nfunc Curtsy() {}\n"
func gitDemoCommits(n int) []mujica.Commit {
out := make([]mujica.Commit, n)
authors := []string{"Sakiko", "Mutsumi", "Uika", "Umiri", "Nyamu"}
for i := range out {
out[i] = mujica.Commit{Hash: fmt.Sprintf("%016x%024x", uint64(n-i)*0x9E3779B97F4A7C15, n-i), Author: authors[i%5],
When: gitDemoTime.Add(-time.Duration(i) * 37 * time.Minute), Subject: fmt.Sprintf("Chapter %d of the chronicle", n-i)}
if i+1 < n {
out[i].Parents = []string{fmt.Sprintf("%016x%024x", uint64(n-i-1)*0x9E3779B97F4A7C15, n-i-1)}
}
}
if n > 0 {
out[0].Refs = []string{"main", "origin/main"}
}
return out
}
func gitDemoGraph() []mujica.Commit {
cs := []mujica.Commit{
{Hash: "e7", Parents: []string{"e5", "f3"}, Subject: "Merge branch 'feature/lanes'", Refs: []string{"main"}},
{Hash: "f3", Parents: []string{"f2"}, Subject: "Color lanes by branch"},
{Hash: "e5", Parents: []string{"e4", "h1"}, Subject: "Merge hotfix"},
{Hash: "h1", Parents: []string{"e3"}, Subject: "Fix the overture timing", Refs: []string{"hotfix"}},
{Hash: "f2", Parents: []string{"f1"}, Subject: "Draw merge lines"},
{Hash: "e4", Parents: []string{"e3"}, Subject: "Tune the ballroom palette"},
{Hash: "f1", Parents: []string{"e3"}, Subject: "Start lane layout"},
{Hash: "e3", Parents: []string{"e2"}, Subject: "Add the commit list"},
{Hash: "e2", Parents: []string{"e1"}, Subject: "Add the blame view"},
{Hash: "e1", Subject: "Initial commit", Refs: []string{"v1.0.0"}},
}
for i := range cs {
cs[i].Author = []string{"Sakiko", "Mutsumi", "Uika"}[i%3]
cs[i].When = gitDemoTime.Add(-time.Duration(i) * 2 * time.Hour)
}
return cs
}
func gitDemoBranches() []mujica.Branch {
return []mujica.Branch{
{Name: "main", Current: true, Upstream: "origin/main", Ahead: 2, Behind: 1},
{Name: "feature/lanes", Upstream: "origin/feature/lanes", Ahead: 5},
{Name: "fix/blame"},
{Name: "origin/main", Remote: true},
{Name: "origin/feature/lanes", Remote: true},
{Name: "origin/release/1.1", Remote: true},
}
}
func gitDiffViewerDemo(c *ui.Context, log func(string)) {
type state struct {
viewer mujica.DiffViewerState
split, plain bool
context float64
}
s := ui.Local(c.Root(), "301", func() state { return state{context: 2} })
navigationDemo(c, func() {
navigationCheck(c, &s.split, "Side by side", log)
navigationCheck(c, &s.plain, "Plain text", log)
navigationSlider(c, &s.context, "Context", 1, 6, log)
}, func() {
lang := "go"
if s.plain {
lang = ""
}
mujica.DiffViewer(c, &s.viewer, gitDemoOld, gitDemoNew, mujica.DiffViewerOptions{SideBySide: s.split, Context: int(s.context), Language: lang}).Height(380)
})
}
func gitInlineDiffDemo(c *ui.Context, log func(string)) {
type state struct {
from, to string
mono bool
}
s := ui.Local(c.Root(), "302", func() state {
return state{from: "The court meets at noon in the east hall.", to: "The council meets at dusk in the east hall."}
})
navigationDemo(c, func() {
navigationCheck(c, &s.mono, "Monospace", log)
}, func() {
if mujica.TextInput(c, &s.from, mujica.TextInputOptions{Label: "Old line"}).Changed() {
log("Old: " + s.from)
}
if mujica.TextInput(c, &s.to, mujica.TextInputOptions{Label: "New line"}).Changed() {
log("New: " + s.to)
}
mujica.InlineDiff(c, s.from, s.to, mujica.InlineDiffOptions{Mono: s.mono})
})
}
func gitThreeWayMergeDemo(c *ui.Context, log func(string)) {
type state struct {
merge mujica.ThreeWayMergeState
disabled bool
}
s := ui.Local(c.Root(), "303", func() state { return state{} })
base := "title: Overture\ntempo: 96\nkey: D minor\nlength: 4:10\nhall: east"
ours := "title: Overture\ntempo: 104\nkey: D minor\nlength: 4:10\nhall: east\nencore: yes"
theirs := "title: Overture\ntempo: 92\nkey: D minor\nlength: 4:30\nhall: east"
navigationDemo(c, func() {
navigationCheck(c, &s.disabled, "Disabled", log)
}, func() {
r := mujica.ThreeWayMerge(c, &s.merge, base, ours, theirs, mujica.ThreeWayMergeOptions{Disabled: s.disabled})
r.Element.Height(360)
if r.Changed() {
done, total := s.merge.Resolved()
log(fmt.Sprintf("Changed: %d of %d resolved", done, total))
}
ui.Text(c, s.merge.Result()).Font("monospace").TextColor(mujica.Tokens(c).TextMuted)
})
}
func gitConflictResolverDemo(c *ui.Context, log func(string)) {
type state struct {
res mujica.ConflictResolverState
disabled bool
}
s := ui.Local(c.Root(), "304", func() state { return state{} })
text := "# Program\n<<<<<<< HEAD\nOpening: Overture in D minor\n=======\nOpening: Prelude in G major\n>>>>>>> feature/program\nInterval: 15 minutes\n<<<<<<< HEAD\nFinale: Masquerade\n=======\nFinale: Ave Mujica\n>>>>>>> feature/program"
navigationDemo(c, func() {
navigationCheck(c, &s.disabled, "Disabled", log)
}, func() {
if mujica.ConflictResolver(c, &s.res, text, mujica.ConflictResolverOptions{Disabled: s.disabled}).Changed() {
done, total := s.res.Resolved()
log(fmt.Sprintf("Changed: %d of %d resolved", done, total))
}
})
}
func gitChangesListDemo(c *ui.Context, log func(string)) {
type state struct {
list mujica.ChangesListState
files []mujica.ChangedFile
disabled bool
}
s := ui.Local(c.Root(), "305", func() state {
return state{files: []mujica.ChangedFile{
{Path: "commit_graph.go", Status: mujica.GitAdded, Staged: true},
{Path: "internal/git.go", Status: mujica.GitModified},
{Path: "docs/old.md", Status: mujica.GitDeleted},
{Path: "notes.txt", Status: mujica.GitUntracked},
{Path: "icons/svg/tag.svg", Status: mujica.GitRenamed},
}}
})
navigationDemo(c, func() {
navigationCheck(c, &s.disabled, "Disabled", log)
}, func() {
r := mujica.ChangesList(c, &s.list, s.files, mujica.ChangesListOptions{Disabled: s.disabled})
r.Element.Height(320)
if a, path, ok := r.Action(); ok {
log("Action: " + a + " " + path)
s.files = gitStage(s.files, a, path)
}
if r.Changed() && s.list.Selected() >= 0 {
log("Selected: " + s.files[s.list.Selected()].Path)
}
})
}
// gitStage applies a stage action to a copy of files.
func gitStage(files []mujica.ChangedFile, action, path string) []mujica.ChangedFile {
out := append([]mujica.ChangedFile(nil), files...)
for i := range out {
switch {
case action == "stage_all":
out[i].Staged = true
case action == "unstage_all":
out[i].Staged = false
case out[i].Path == path:
out[i].Staged = action == "stage"
}
}
return out
}
func gitCommitInputDemo(c *ui.Context, log func(string)) {
type state struct {
msg mujica.CommitMessage
busy, disabled bool
until time.Time
}
s := ui.Local(c.Root(), "306", func() state { return state{} })
if s.busy && c.Now().After(s.until) {
s.busy = false
s.msg = mujica.CommitMessage{}
log("Committed")
}
if s.busy {
c.After(s.until.Sub(c.Now()))
}
navigationDemo(c, func() {
navigationCheck(c, &s.disabled, "Disabled", log)
}, func() {
r := mujica.CommitInput(c, &s.msg, mujica.CommitInputOptions{Busy: s.busy, Disabled: s.disabled})
if r.Committed() || r.Amended() {
log(fmt.Sprintf("Submit: amend=%v title=%q", r.Amended(), s.msg.Title))
s.busy, s.until = true, c.Now().Add(1200*time.Millisecond)
}
})
}
func gitCommitGraphDemo(c *ui.Context, log func(string)) {
type state struct {
graph mujica.CommitGraphState
big bool
}
s := ui.Local(c.Root(), "307", func() state { return state{} })
small, big := gitDemoGraph(), gitDemoCommits(100000)
navigationDemo(c, func() {
navigationCheck(c, &s.big, "100,000 commits", log)
}, func() {
commits := small
if s.big {
commits = big
}
r := mujica.CommitGraph(c, &s.graph, commits, mujica.CommitGraphOptions{})
r.Element.Height(380)
if hash, ok := s.graph.List.Selected(); ok && (r.Changed() || r.Submitted()) {
log(fmt.Sprintf("Selected %s, open=%v", hash[:min(7, len(hash))], r.Submitted()))
}
})
}
func gitCommitListDemo(c *ui.Context, log func(string)) {
type state struct {
list mujica.ListState[string]
loading bool
}
s := ui.Local(c.Root(), "308", func() state { return state{} })
commits := gitDemoCommits(40)
navigationDemo(c, func() {
navigationCheck(c, &s.loading, "Loading more", log)
}, func() {
st := mujica.DataReady
if s.loading {
st = mujica.DataLoadingMore
}
r := mujica.CommitList(c, &s.list, commits, mujica.CommitListOptions{Status: st})
r.Element.Height(360)
if hash, ok := s.list.Selected(); ok && (r.Changed() || r.Submitted()) {
log(fmt.Sprintf("Selected %s, open=%v", hash[:7], r.Submitted()))
}
})
}
func gitBranchSelectorDemo(c *ui.Context, log func(string)) {
type state struct {
branch string
create, disabled bool
branches []mujica.Branch
}
s := ui.Local(c.Root(), "309", func() state { return state{branch: "main", create: true, branches: gitDemoBranches()} })
navigationDemo(c, func() {
navigationCheck(c, &s.create, "Allow create", log)
navigationCheck(c, &s.disabled, "Disabled", log)
}, func() {
r := mujica.BranchSelector(c, &s.branch, s.branches, mujica.BranchSelectorOptions{AllowCreate: s.create, Disabled: s.disabled})
if r.Changed() {
log("Changed: " + s.branch)
}
if name, ok := r.Created(); ok {
log("Create: " + name)
s.branches = append(append([]mujica.Branch(nil), s.branches...), mujica.Branch{Name: name})
s.branch = name
}
})
}
func gitBranchListDemo(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "310", func() mujica.ListState[string] { return mujica.ListState[string]{} })
actions := []mujica.GitAction{{ID: "checkout", Label: "Check out", Icon: "git-branch"}, {ID: "delete", Label: "Delete", Icon: "trash-2"}}
r := mujica.BranchList(c, s, gitDemoBranches(), mujica.BranchListOptions{Actions: actions})
r.Element.Height(300)
if a, name, ok := r.Action(); ok {
log("Action: " + a + " " + name)
}
if key, ok := s.Selected(); ok && r.Changed() {
log("Selected: " + key)
}
}
func gitTagListDemo(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "311", func() mujica.ListState[string] { return mujica.ListState[string]{} })
tags := []mujica.GitTag{
{Name: "v1.1.0", Target: "9e3779b97f4a7c15", Annotation: "Five hundred components"},
{Name: "v1.0.0", Target: "3c6ef372fe94f82a", Annotation: "The first hundred"},
{Name: "nightly", Target: "daa66d2c7ddf743f"},
}
r := mujica.TagList(c, s, tags, mujica.TagListOptions{})
r.Element.Height(220)
if _, hash, ok := r.Action(); ok {
log("Jump to " + hash[:7])
}
if key, ok := s.Selected(); ok && r.Changed() {
log("Selected: " + key)
}
}
func gitStashListDemo(c *ui.Context, log func(string)) {
type state struct {
list mujica.ListState[string]
stashes []mujica.Stash
}
s := ui.Local(c.Root(), "312", func() state {
return state{stashes: []mujica.Stash{
{Ref: "stash@{0}", Message: "WIP on main: lane colors", Branch: "main"},
{Ref: "stash@{1}", Message: "Try serif headings", Branch: "feature/type"},
{Ref: "stash@{2}", Message: "Blame hover experiment", Branch: "fix/blame"},
}}
})
r := mujica.StashList(c, &s.list, s.stashes, mujica.StashListOptions{})
r.Element.Height(240)
if a, ref, ok := r.Action(); ok {
log("Action: " + a + " " + ref)
if a != "apply" {
var keep []mujica.Stash
for _, st := range s.stashes {
if st.Ref != ref {
keep = append(keep, st)
}
}
s.stashes = keep
}
}
}
func gitBlameViewDemo(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "313", func() mujica.BlameViewState { return mujica.BlameViewState{} })
var lines []mujica.BlameLine
for i, l := range strings.Split(strings.TrimSuffix(gitDemoNew, "\n"), "\n") {
who := []mujica.BlameLine{
{Hash: "9e3779b97f4a7c15", Author: "Sakiko", When: gitDemoTime, Summary: "Create the court package"},
{Hash: "3c6ef372fe94f82a", Author: "Uika", When: gitDemoTime.Add(-48 * time.Hour), Summary: "Greet guests by title"},
}[min(i/4%2, 1)]
who.Text = l
lines = append(lines, who)
}
mujica.BlameView(c, s, lines, mujica.BlameViewOptions{Language: "go"}).Height(380)
ui.Text(c, "Hover the left column for commit details.").TextColor(mujica.Tokens(c).TextMuted)
}
func gitFileHistoryDemo(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "314", func() mujica.ListState[string] { return mujica.ListState[string]{} })
r := mujica.FileHistory(c, s, "court/greet.go", gitDemoCommits(12), mujica.FileHistoryOptions{})
r.Element.Height(360)
if _, hash, ok := r.Action(); ok {
log("Diff at " + hash[:7])
}
if key, ok := s.Selected(); ok && r.Changed() {
log("Selected: " + key[:7])
}
}
+48
View File
@@ -0,0 +1,48 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
)
// FileHistoryOptions configure a FileHistory.
type FileHistoryOptions struct {
// TimeFormat lays out commit times; empty means "2006-01-02 15:04".
TimeFormat string
Status DataStatus
Error string
}
// FileHistory lists commits that changed path; Action "diff" opens one.
func FileHistory(c *ui.Context, s *ListState[string], path string, commits []Commit, opts FileHistoryOptions) GitListResult {
if path == "" {
panic("mujica: FileHistory needs a path")
}
k := Tokens(c)
u := 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, msg(c, "file_history.title")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Shrink(0)
})
actions := []GitAction{{ID: "diff", Label: msg(c, "file_history.diff"), Icon: "arrow-left-right"}}
v := List(c, s, len(commits), 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: 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
}
+65
View File
@@ -0,0 +1,65 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestFileHistoryDiffAction(t *testing.T) {
var s ListState[string]
commits := gitHistory(3)
diff := ""
tt := ui.NewTester(func(c *ui.Context) {
Use(c, 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 ListState[string]
tt := ui.NewTester(func(c *ui.Context) {
Use(c, 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) {
Use(c, Settings{})
var s 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() *ListState[string] { return &ListState[string]{} })
ui.Column(c).Padding(16).Children(func() {
FileHistory(c, *s, "internal/git.go", commits, FileHistoryOptions{}).Element.Height(170)
})
})
}
+72
View File
@@ -0,0 +1,72 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"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 := Tokens(c)
name := msg(c, "git_status_badge."+gitStatusKeys[gitStatusIndex(status)])
col := gitStatusColor(k, status)
row := ui.Row(c).AlignItems(ui.Center).Gap(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(FontSize(c, theme.CaptionSize)).TextColor(col)
})
if opts.ShowLabel {
ui.Text(c, name).FontSize(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
}
+65
View File
@@ -0,0 +1,65 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestGitStatusBadgeLettersAndNames(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, 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) {
Use(c, Settings{Locale: 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) {
Use(c, 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})
}
})
})
})
}
+16
View File
@@ -0,0 +1,16 @@
<svg
class="lucide lucide-archive"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="20" height="5" x="2" y="3" rx="1" />
<path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8" />
<path d="M10 12h4" />
</svg>

After

Width:  |  Height:  |  Size: 372 B

+17
View File
@@ -0,0 +1,17 @@
<svg
class="lucide lucide-arrow-left-right"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M8 3 4 7l4 4" />
<path d="M4 7h16" />
<path d="m16 21 4-4-4-4" />
<path d="M20 17H4" />
</svg>

After

Width:  |  Height:  |  Size: 354 B

+15
View File
@@ -0,0 +1,15 @@
<svg
class="lucide lucide-chevrons-up-down"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m7 15 5 5 5-5" />
<path d="m7 9 5-5 5 5" />
</svg>

After

Width:  |  Height:  |  Size: 306 B

+16
View File
@@ -0,0 +1,16 @@
<svg
class="lucide lucide-git-branch"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M15 6a9 9 0 0 0-9 9V3" />
<circle cx="18" cy="6" r="3" />
<circle cx="6" cy="18" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 348 B

+16
View File
@@ -0,0 +1,16 @@
<svg
class="lucide lucide-git-commit-horizontal"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="3" />
<line x1="3" x2="9" y1="12" y2="12" />
<line x1="15" x2="21" y1="12" y2="12" />
</svg>

After

Width:  |  Height:  |  Size: 373 B

+16
View File
@@ -0,0 +1,16 @@
<svg
class="lucide lucide-git-merge"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="18" cy="18" r="3" />
<circle cx="6" cy="6" r="3" />
<path d="M6 21V9a9 9 0 0 0 9 9" />
</svg>

After

Width:  |  Height:  |  Size: 347 B

+17
View File
@@ -0,0 +1,17 @@
<svg
class="lucide lucide-git-pull-request"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="18" cy="18" r="3" />
<circle cx="6" cy="6" r="3" />
<path d="M13 6h3a2 2 0 0 1 2 2v7" />
<line x1="6" x2="6" y1="9" y2="21" />
</svg>

After

Width:  |  Height:  |  Size: 396 B

+16
View File
@@ -0,0 +1,16 @@
<svg
class="lucide lucide-history"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M12 7v5l4 2" />
</svg>

After

Width:  |  Height:  |  Size: 356 B

+14
View File
@@ -0,0 +1,14 @@
<svg
class="lucide lucide-message-square"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z" />
</svg>

After

Width:  |  Height:  |  Size: 378 B

+15
View File
@@ -0,0 +1,15 @@
<svg
class="lucide lucide-reply"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 18v-2a4 4 0 0 0-4-4H4" />
<path d="m9 17-5-5 5-5" />
</svg>

After

Width:  |  Height:  |  Size: 308 B

+15
View File
@@ -0,0 +1,15 @@
<svg
class="lucide lucide-rotate-ccw"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>

After

Width:  |  Height:  |  Size: 332 B

+15
View File
@@ -0,0 +1,15 @@
<svg
class="lucide lucide-tag"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z" />
<circle cx="7.5" cy="7.5" r=".5" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 458 B

+66
View File
@@ -0,0 +1,66 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"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 := 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, "−", msg(c, "inline_diff.removed"), w.a, k.Danger, k)
inlineDiffLine(c, "+", 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 := 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)
})
}
+49
View File
@@ -0,0 +1,49 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestInlineDiffNamesAndShowsBothLines(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, 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) {
Use(c, 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})
})
})
}
+59 -1
View File
@@ -1,6 +1,9 @@
package internal
import "strings"
import (
"strings"
"unicode"
)
// DiffOp says a line is kept, removed or added.
type DiffOp uint8
@@ -119,6 +122,61 @@ func myers(out []DiffLine, a, b []string, ao, bo int) []DiffLine {
return out
}
// WordSpan is a run of a line that a word diff kept, removed or added.
type WordSpan struct {
Op DiffOp
Text string
}
// WordDiff compares two lines by words: from holds kept and removed spans, to kept and added.
func WordDiff(a, b string) (from, to []WordSpan) {
add := func(spans []WordSpan, op DiffOp, s string) []WordSpan {
if n := len(spans); n > 0 && spans[n-1].Op == op {
spans[n-1].Text += s
return spans
}
return append(spans, WordSpan{op, s})
}
for _, d := range LineDiff(wordTokens(a), wordTokens(b)) {
switch d.Op {
case DiffEqual:
from, to = add(from, DiffEqual, d.Text), add(to, DiffEqual, d.Text)
case DiffDelete:
from = add(from, DiffDelete, d.Text)
case DiffInsert:
to = add(to, DiffInsert, d.Text)
}
}
return from, to
}
// wordTokens splits s into words, runs of spaces and single other characters.
func wordTokens(s string) []string {
var out []string
kind := func(r rune) int {
switch {
case r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r):
return 1
case unicode.IsSpace(r):
return 2
}
return 0
}
start, prev := 0, -1
for i, r := range s {
k := kind(r)
if i > start && (k != prev || k == 0) {
out = append(out, s[start:i])
start = i
}
prev = k
}
if start < len(s) {
out = append(out, s[start:])
}
return out
}
// DiffStats counts the added and removed lines of a diff.
func DiffStats(d []DiffLine) (added, removed int) {
for _, l := range d {
+38
View File
@@ -0,0 +1,38 @@
package internal
import (
"slices"
"testing"
)
func TestWordTokens(t *testing.T) {
got := wordTokens("let x_1 = foo(bar); ok")
want := []string{"let", " ", "x_1", " ", "=", " ", "foo", "(", "bar", ")", ";", " ", "ok"}
if !slices.Equal(got, want) {
t.Fatalf("tokens %q", got)
}
if wordTokens("") != nil {
t.Fatal("empty line has tokens")
}
}
func TestWordDiff(t *testing.T) {
from, to := WordDiff("return a + b", "return a - total")
wantFrom := []WordSpan{{DiffEqual, "return a "}, {DiffDelete, "+"}, {DiffEqual, " "}, {DiffDelete, "b"}}
wantTo := []WordSpan{{DiffEqual, "return a "}, {DiffInsert, "-"}, {DiffEqual, " "}, {DiffInsert, "total"}}
if !slices.Equal(from, wantFrom) || !slices.Equal(to, wantTo) {
t.Fatalf("from %+v to %+v", from, to)
}
join := func(s []WordSpan) (out string) {
for _, w := range s {
out += w.Text
}
return
}
for _, p := range [][2]string{{"", "new text"}, {"old text", ""}, {"same", "same"}, {"中文 词语", "中文 句子"}} {
f, to := WordDiff(p[0], p[1])
if join(f) != p[0] || join(to) != p[1] {
t.Errorf("%q→%q rebuilt %q→%q", p[0], p[1], join(f), join(to))
}
}
}
+76
View File
@@ -0,0 +1,76 @@
package internal
import (
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// CodeSpans colors a code line; inBlock carries block comments.
func CodeSpans(k theme.Tokens, language, line string, inBlock bool) ([]ui.Span, bool) {
toks, open := Highlight(language, line, inBlock)
spans := make([]ui.Span, 0, len(toks))
for _, t := range toks {
s := ui.Span{Text: t.Text, Color: k.Text}
switch t.Kind {
case TokKeyword:
s.Color, s.Weight = k.AccentText, 600
case TokString:
s.Color = k.Success
case TokNumber:
s.Color = k.Info
case TokComment:
s.Color, s.Italic = k.TextMuted, true
case TokType:
s.Color = k.Warning
case TokPunct:
s.Color = k.TextMuted
}
spans = append(spans, s)
}
return spans, open
}
// ansiLight and ansiDark are 16 colors readable on Surface.
var (
ansiLight = [16]string{"#211C1D", "#A12635", "#326247", "#805714", "#355E80", "#7A3B78", "#2F6B6B", "#6A5B55",
"#6A5B55", "#B0303F", "#3B7352", "#8E6417", "#3F6E94", "#8A4787", "#367A7A", "#211C1D"}
ansiDark = [16]string{"#A89A95", "#F0A1AA", "#94C6A4", "#DFC080", "#9DBDDA", "#D9A6D6", "#8FCFCF", "#F2EBDD",
"#BCAEAA", "#F5B7BE", "#ADD6B9", "#E9CF99", "#B5CDE4", "#E4BDE2", "#A9DCDC", "#FFFFFF"}
ansiCube = [6]uint8{0, 95, 135, 175, 215, 255}
)
// AnsiResolve resolves a color; ok is false for default.
func AnsiResolve(dark bool, col AnsiColor) (ui.Color, bool) {
switch col.Kind {
case AnsiRGB:
return ui.RGB(col.R, col.G, col.B), true
case AnsiIndexed:
i := col.Index
switch {
case i < 16 && dark:
return ui.Hex(ansiDark[i]), true
case i < 16:
return ui.Hex(ansiLight[i]), true
case i < 232:
i -= 16
return ui.RGB(ansiCube[i/36], ansiCube[i/6%6], ansiCube[i%6]), true
default:
g := 8 + 10*(i-232)
return ui.RGB(g, g, g), true
}
}
return ui.Color{}, false
}
// StripAnsi drops SGR escapes from s, keeping its text.
func StripAnsi(s string) string {
spans, _ := ParseAnsi(s, AnsiStyle{})
var b strings.Builder
for _, sp := range spans {
b.WriteString(sp.Text)
}
return b.String()
}
+41
View File
@@ -0,0 +1,41 @@
package internal
import (
"testing"
"mujicaui/theme"
)
func TestAnsiPaletteReadable(t *testing.T) {
for _, k := range []theme.Tokens{theme.Light(), theme.Dark()} {
for i := range 16 {
col, ok := AnsiResolve(k.Dark, AnsiColor{Kind: AnsiIndexed, Index: uint8(i)})
if !ok {
t.Fatalf("index %d unresolved", i)
}
if r := theme.Contrast(col, k.Surface); r < 4.5 {
t.Errorf("dark=%v color %d contrast %.2f", k.Dark, i, r)
}
}
}
if c, _ := AnsiResolve(false, AnsiColor{Kind: AnsiIndexed, Index: 196}); c.R != 255 || c.G != 0 || c.B != 0 {
t.Errorf("cube 196 = %v", c)
}
if c, _ := AnsiResolve(false, AnsiColor{Kind: AnsiIndexed, Index: 244}); c.R != 128 {
t.Errorf("gray 244 = %v", c)
}
if _, ok := AnsiResolve(true, AnsiColor{}); ok {
t.Error("default color resolved")
}
}
func TestCodeSpansColorKeywords(t *testing.T) {
k := theme.Light()
spans, _ := CodeSpans(k, "go", "func main() // hi", false)
if spans[0].Text != "func" || spans[0].Color != k.AccentText {
t.Fatalf("keyword span %+v", spans[0])
}
if last := spans[len(spans)-1]; last.Color != k.TextMuted || !last.Italic {
t.Fatalf("comment span %+v", last)
}
}
+114
View File
@@ -0,0 +1,114 @@
package mujica
func init() {
addMessages(map[string][2]string{
"git_status_badge.modified": {"Modified", "已修改"},
"git_status_badge.added": {"Added", "已添加"},
"git_status_badge.deleted": {"Deleted", "已删除"},
"git_status_badge.untracked": {"Untracked", "未跟踪"},
"git_status_badge.renamed": {"Renamed", "已重命名"},
"git_status_badge.copied": {"Copied", "已复制"},
"diff_stat.label": {"%d additions, %d deletions", "新增 %d 行,删除 %d 行"},
"command_block.copy_command": {"Copy command", "复制命令"},
"command_block.copy_output": {"Copy output", "复制输出"},
"command_block.exit": {"exit %d", "退出码 %d"},
"command_block.no_output": {"No output", "无输出"},
"command_block.output": {"Command output", "命令输出"},
"command_block.running": {"Running", "运行中"},
"commit_list.empty": {"No commits", "没有提交"},
"branch_list.ahead": {"%d ahead of upstream", "领先上游 %d 个提交"},
"branch_list.behind": {"%d behind upstream", "落后上游 %d 个提交"},
"branch_list.current": {"current", "当前"},
"branch_list.empty": {"No branches", "没有分支"},
"branch_selector.create": {"Create branch", "新建分支"},
"branch_selector.empty": {"No matching branches", "没有匹配的分支"},
"branch_selector.label": {"Branch", "分支"},
"branch_selector.search": {"Search branches", "搜索分支"},
"branch_selector.local": {"Local", "本地"},
"branch_selector.remote": {"Remote", "远端"},
"tag_list.empty": {"No tags", "没有标签"},
"tag_list.jump": {"Go to commit", "跳转到提交"},
"stash_list.apply": {"Apply", "应用"},
"stash_list.pop": {"Pop", "弹出"},
"stash_list.drop": {"Drop", "删除"},
"stash_list.empty": {"No stashes", "没有贮藏"},
"blame_view.line": {"Line %d, %s, %s", "第 %d 行,%s,%s"},
"file_history.title": {"History", "历史"},
"file_history.diff": {"View changes", "查看差异"},
"file_history.empty": {"No history for this file", "此文件没有历史"},
"pull_request_card.open": {"Open", "开放"},
"pull_request_card.draft": {"Draft", "草稿"},
"pull_request_card.merged": {"Merged", "已合并"},
"pull_request_card.closed": {"Closed", "已关闭"},
"pull_request_card.files": {"%d files", "%d 个文件"},
"pull_request_card.passed": {"%d checks passed", "%d 项检查通过"},
"pull_request_card.failed": {"%d checks failed", "%d 项检查失败"},
"pull_request_card.pending": {"%d checks pending", "%d 项检查进行中"},
"pull_request_card.approved": {"approved", "已批准"},
"pull_request_card.changes": {"changes requested", "要求修改"},
"pull_request_card.review_pending": {"review pending", "待评审"},
"review_comment.placeholder": {"Write a reply", "写下回复"},
"review_comment.reply": {"Reply", "回复"},
"review_comment.field": {"Reply text", "回复内容"},
"review_comment.resolve": {"Resolve", "解决"},
"review_comment.reopen": {"Reopen", "重新打开"},
"review_comment.resolved": {"Resolved", "已解决"},
"changes_list.staged": {"Staged changes (%d)", "已暂存的更改(%d)"},
"changes_list.unstaged": {"Changes (%d)", "更改(%d)"},
"changes_list.stage": {"Stage", "暂存"},
"changes_list.unstage": {"Unstage", "取消暂存"},
"changes_list.stage_all": {"Stage all", "全部暂存"},
"changes_list.unstage_all": {"Unstage all", "全部取消暂存"},
"diff_viewer.added": {"Added", "新增"},
"diff_viewer.deleted": {"Deleted", "删除"},
"diff_viewer.fold": {"%d unchanged lines", "%d 行未更改"},
"inline_diff.removed": {"Old", "原文"},
"inline_diff.added": {"New", "新文"},
"three_way_merge.resolved": {"%d of %d conflicts resolved", "已解决 %d / %d 个冲突"},
"three_way_merge.ours": {"Local", "本地"},
"three_way_merge.base": {"Base", "基准"},
"three_way_merge.theirs": {"Remote", "远端"},
"three_way_merge.result": {"Result", "结果"},
"three_way_merge.unresolved": {"Unresolved", "未解决"},
"three_way_merge.empty": {"No lines", "无内容"},
"three_way_merge.take_ours": {"Take local", "采纳本地"},
"three_way_merge.take_theirs": {"Take remote", "采纳远端"},
"three_way_merge.take_both": {"Take both", "两者都要"},
"three_way_merge.take_base": {"Keep base", "保留基准"},
"conflict_resolver.resolved": {"%d of %d conflicts resolved", "已解决 %d / %d 个冲突"},
"conflict_resolver.broken": {"Conflict markers are not closed after line %d", "第 %d 行起的冲突标记未闭合"},
"conflict_resolver.none": {"No conflicts", "没有冲突"},
"conflict_resolver.title": {"Conflict %d · line %d", "冲突 %d · 第 %d 行"},
"conflict_resolver.open": {"unresolved", "未解决"},
"conflict_resolver.done": {"resolved", "已解决"},
"conflict_resolver.current": {"Current", "当前"},
"conflict_resolver.incoming": {"Incoming", "传入"},
"conflict_resolver.take_current": {"Accept current", "采纳当前"},
"conflict_resolver.take_incoming": {"Accept incoming", "采纳传入"},
"conflict_resolver.take_both": {"Accept both", "两者都采纳"},
"conflict_resolver.reset": {"Undo", "撤销"},
"commit_input.title": {"Summary", "提交标题"},
"commit_input.body": {"Description", "提交说明"},
"commit_input.count": {"%d of %d characters", "%d / %d 字符"},
"commit_input.long": {"Summary is %d characters over the suggested length", "标题超出建议长度 %d 个字符"},
"commit_input.commit": {"Commit", "提交"},
"commit_input.amend": {"Amend", "修订"},
})
}
+179
View File
@@ -0,0 +1,179 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"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) 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 := Tokens(c)
u := Density(c).Unit()
small := 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 := 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(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.changed = 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(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 := Tokens(c)
u := 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, msg(c, "pull_request_card."+key)).FontSize(FontSize(c, theme.CaptionSize)).FontWeight(600).SingleLine()
})
}
func pullRequestChecks(c *ui.Context, pr PullRequest) {
k := Tokens(c)
u := 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(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 := 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 + ": " + msg(c, key)
ui.Box(c).Size(28, 28).Shrink(0).Label(name).Tooltip(name).Children(func() {
Avatar(c, r.Name, 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) })
})
}
+82
View File
@@ -0,0 +1,82 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
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) {
Use(c, 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) {
Use(c, 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) {
Use(c, 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{})
})
})
}
+127
View File
@@ -0,0 +1,127 @@
package mujica
import (
"fmt"
"log/slog"
"strings"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"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 := Tokens(c)
u := Density(c).Unit()
small := 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, 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() {
Avatar(c, m.Author, 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 := TextArea(c, draft, TextAreaOptions{Placeholder: msg(c, "review_comment.placeholder"), Label: 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 Button(c, msg(c, "review_comment.resolve"), ButtonOptions{Variant: Secondary, Icon: icons.Must("check"), Disabled: opts.Disabled}).Clicked() {
res.resolved = true
}
if Button(c, msg(c, "review_comment.reply"), 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 Button(c, msg(c, "review_comment.reopen"), ButtonOptions{Variant: 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
}
+97
View File
@@ -0,0 +1,97 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
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) {
Use(c, 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) {
Use(c, 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) {
Use(c, 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{})
})
})
}
+58
View File
@@ -0,0 +1,58 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"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 DataStatus
Error string
}
// StashList shows stashes with apply, pop and drop actions.
func StashList(c *ui.Context, s *ListState[string], stashes []Stash, opts StashListOptions) GitListResult {
var r GitListResult
k := Tokens(c)
actions := []GitAction{
{ID: "apply", Label: msg(c, "stash_list.apply"), Icon: "check"},
{ID: "pop", Label: msg(c, "stash_list.pop"), Icon: "upload"},
{ID: "drop", Label: msg(c, "stash_list.drop"), Icon: "trash-2"},
}
v := List(c, s, len(stashes), 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: msg(c, "stash_list.empty"),
}, func(i int) {
st := stashes[i]
small := 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
}
+72
View File
@@ -0,0 +1,72 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
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 ListState[string]
var acted [][2]string
tt := ui.NewTester(func(c *ui.Context) {
Use(c, 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 ListState[string]
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Locale: 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) {
Use(c, Settings{})
var s 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() *ListState[string] { return &ListState[string]{} })
ui.Column(c).Padding(16).Children(func() {
StashList(c, *s, stashSample(), StashListOptions{}).Element.Height(100)
})
})
}
+50
View File
@@ -0,0 +1,50 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"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 DataStatus
Error string
}
// TagList shows tags; Action "jump" carries the target hash.
func TagList(c *ui.Context, s *ListState[string], tags []GitTag, opts TagListOptions) GitListResult {
var r GitListResult
k := Tokens(c)
v := List(c, s, len(tags), 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: msg(c, "tag_list.empty"),
}, func(i int) {
t := tags[i]
small := 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: 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
}
+64
View File
@@ -0,0 +1,64 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
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 ListState[string]
jump := ""
tt := ui.NewTester(func(c *ui.Context) {
Use(c, 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) {
Use(c, Settings{})
var s 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() *ListState[string] { return &ListState[string]{} })
ui.Column(c).Padding(16).Children(func() {
TagList(c, *s, tagSample(), TagListOptions{}).Element.Height(100)
})
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff. Show more