Merge branch 'worktree-agent-acb1218b465c60e42'
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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{})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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{})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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") })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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。
|
||||
- 长行不换行,超出部分裁剪。
|
||||
- 文本换成新字符串后折叠全部重置。
|
||||
@@ -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。
|
||||
@@ -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 选择。
|
||||
|
||||
## 限制
|
||||
|
||||
- 按行合并,不做词级合并。
|
||||
- 相邻(中间没有未变更行)的两侧修改视为冲突。
|
||||
@@ -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 样式的 ||||||| 基准段,但不显示它。
|
||||
- 冲突块不虚拟化;适合单个文件的冲突数量。
|
||||
@@ -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 执行。
|
||||
|
||||
## 限制
|
||||
|
||||
- 不读取仓库:文件列表与暂存操作都由调用方完成。
|
||||
- 重命名的旧路径不单独显示。
|
||||
@@ -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 时组件保持锁定。
|
||||
- 不做提交信息格式校验。
|
||||
@@ -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 时,右侧泳道被裁剪。
|
||||
@@ -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 打开。
|
||||
|
||||
## 限制
|
||||
|
||||
- 时间按调用方给定格式显示,不做相对时间。
|
||||
@@ -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:选择与新建都由调用方处理。
|
||||
- 搜索为大小写不敏感的子串匹配。
|
||||
@@ -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 等组件。
|
||||
@@ -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 到达。
|
||||
|
||||
## 限制
|
||||
|
||||
- 不读取标签:数据由调用方提供。
|
||||
@@ -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 执行。
|
||||
|
||||
## 限制
|
||||
|
||||
- 删除不带确认;需要时由调用方在回调里确认。
|
||||
@@ -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 提示,只能用指针触发;每行左栏的名称同样朗读作者与提交。
|
||||
@@ -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 到达。
|
||||
|
||||
## 限制
|
||||
|
||||
- 不跟踪重命名:提交列表由调用方提供。
|
||||
@@ -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 打开。
|
||||
|
||||
## 限制
|
||||
|
||||
- 数据由调用方获取;组件不刷新检查状态。
|
||||
@@ -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。
|
||||
- 线程状态由调用方更新。
|
||||
@@ -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 行”。
|
||||
|
||||
## 事件
|
||||
|
||||
- 无。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- 无:纯展示,不获取焦点。
|
||||
|
||||
## 限制
|
||||
|
||||
- 无。
|
||||
@@ -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。
|
||||
- 名称写入朗读文本与提示,不只靠颜色。
|
||||
|
||||
## 事件
|
||||
|
||||
- 无。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- 无:纯展示。
|
||||
|
||||
## 限制
|
||||
|
||||
- 无。
|
||||
@@ -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 到达。
|
||||
|
||||
## 限制
|
||||
|
||||
- 不运行命令:输出与状态由调用方提供。
|
||||
@@ -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 色与真彩不做对比度调整。
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.",
|
||||
},
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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", "修订"},
|
||||
})
|
||||
}
|
||||
@@ -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) })
|
||||
})
|
||||
}
|
||||
@@ -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{})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 2.9 KiB |