diff --git a/ansi_text.go b/ansi_text.go
new file mode 100644
index 0000000..0795f99
--- /dev/null
+++ b/ansi_text.go
@@ -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
+}
diff --git a/ansi_text_test.go b/ansi_text_test.go
new file mode 100644
index 0000000..1107284
--- /dev/null
+++ b/ansi_text_test.go
@@ -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{})
+ })
+ })
+}
diff --git a/blame_view.go b/blame_view.go
new file mode 100644
index 0000000..0bac5bc
--- /dev/null
+++ b/blame_view.go
@@ -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
+}
diff --git a/blame_view_test.go b/blame_view_test.go
new file mode 100644
index 0000000..3120bf7
--- /dev/null
+++ b/blame_view_test.go
@@ -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)
+ })
+ })
+}
diff --git a/branch_list.go b/branch_list.go
new file mode 100644
index 0000000..7e7cfe7
--- /dev/null
+++ b/branch_list.go
@@ -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)
+ }
+ }
+}
diff --git a/branch_list_test.go b/branch_list_test.go
new file mode 100644
index 0000000..51afde4
--- /dev/null
+++ b/branch_list_test.go
@@ -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)
+ })
+ })
+}
diff --git a/branch_selector.go b/branch_selector.go
new file mode 100644
index 0000000..642b4ab
--- /dev/null
+++ b/branch_selector.go
@@ -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
+}
diff --git a/branch_selector_test.go b/branch_selector_test.go
new file mode 100644
index 0000000..33ec927
--- /dev/null
+++ b/branch_selector_test.go
@@ -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")
+ })
+}
diff --git a/changes_list.go b/changes_list.go
new file mode 100644
index 0000000..c313e18
--- /dev/null
+++ b/changes_list.go
@@ -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
+}
diff --git a/changes_list_test.go b/changes_list_test.go
new file mode 100644
index 0000000..0cf540d
--- /dev/null
+++ b/changes_list_test.go
@@ -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)
+ })
+ })
+}
diff --git a/command_block.go b/command_block.go
new file mode 100644
index 0000000..194e807
--- /dev/null
+++ b/command_block.go
@@ -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)
+}
diff --git a/command_block_test.go b/command_block_test.go
new file mode 100644
index 0000000..589b857
--- /dev/null
+++ b/command_block_test.go
@@ -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{})
+ })
+ })
+}
diff --git a/commit_graph.go b/commit_graph.go
new file mode 100644
index 0000000..1bb1bde
--- /dev/null
+++ b/commit_graph.go
@@ -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)
+}
diff --git a/commit_graph_test.go b/commit_graph_test.go
new file mode 100644
index 0000000..2945268
--- /dev/null
+++ b/commit_graph_test.go
@@ -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)
+ })
+ })
+}
diff --git a/commit_input.go b/commit_input.go
new file mode 100644
index 0000000..4ec91fe
--- /dev/null
+++ b/commit_input.go
@@ -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
+}
diff --git a/commit_input_test.go b/commit_input_test.go
new file mode 100644
index 0000000..ae70439
--- /dev/null
+++ b/commit_input_test.go
@@ -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{})
+ })
+ })
+}
diff --git a/commit_list.go b/commit_list.go
new file mode 100644
index 0000000..161e7e6
--- /dev/null
+++ b/commit_list.go
@@ -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
+}
diff --git a/commit_list_test.go b/commit_list_test.go
new file mode 100644
index 0000000..1dd521f
--- /dev/null
+++ b/commit_list_test.go
@@ -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)
+ })
+ })
+}
diff --git a/conflict_resolver.go b/conflict_resolver.go
new file mode 100644
index 0000000..9806b4e
--- /dev/null
+++ b/conflict_resolver.go
@@ -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
+}
diff --git a/conflict_resolver_test.go b/conflict_resolver_test.go
new file mode 100644
index 0000000..9318d60
--- /dev/null
+++ b/conflict_resolver_test.go
@@ -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") })
+}
diff --git a/diff_stat.go b/diff_stat.go
new file mode 100644
index 0000000..cba272f
--- /dev/null
+++ b/diff_stat.go
@@ -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
+}
diff --git a/diff_stat_test.go b/diff_stat_test.go
new file mode 100644
index 0000000..feeefc1
--- /dev/null
+++ b/diff_stat_test.go
@@ -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})
+ })
+ })
+}
diff --git a/diff_viewer.go b/diff_viewer.go
new file mode 100644
index 0000000..839c0d7
--- /dev/null
+++ b/diff_viewer.go
@@ -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
+}
diff --git a/diff_viewer_test.go b/diff_viewer_test.go
new file mode 100644
index 0000000..702a6e7
--- /dev/null
+++ b/diff_viewer_test.go
@@ -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)
+ })
+ })
+}
diff --git a/docs/components/301-diff-viewer.md b/docs/components/301-diff-viewer.md
new file mode 100644
index 0000000..e002e9f
--- /dev/null
+++ b/docs/components/301-diff-viewer.md
@@ -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。
+- 长行不换行,超出部分裁剪。
+- 文本换成新字符串后折叠全部重置。
diff --git a/docs/components/302-inline-diff.md b/docs/components/302-inline-diff.md
new file mode 100644
index 0000000..f3e5410
--- /dev/null
+++ b/docs/components/302-inline-diff.md
@@ -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。
diff --git a/docs/components/303-three-way-merge.md b/docs/components/303-three-way-merge.md
new file mode 100644
index 0000000..c03a2be
--- /dev/null
+++ b/docs/components/303-three-way-merge.md
@@ -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 选择。
+
+## 限制
+
+- 按行合并,不做词级合并。
+- 相邻(中间没有未变更行)的两侧修改视为冲突。
diff --git a/docs/components/304-conflict-resolver.md b/docs/components/304-conflict-resolver.md
new file mode 100644
index 0000000..8f7fed8
--- /dev/null
+++ b/docs/components/304-conflict-resolver.md
@@ -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 样式的 ||||||| 基准段,但不显示它。
+- 冲突块不虚拟化;适合单个文件的冲突数量。
diff --git a/docs/components/305-changes-list.md b/docs/components/305-changes-list.md
new file mode 100644
index 0000000..54ae845
--- /dev/null
+++ b/docs/components/305-changes-list.md
@@ -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 执行。
+
+## 限制
+
+- 不读取仓库:文件列表与暂存操作都由调用方完成。
+- 重命名的旧路径不单独显示。
diff --git a/docs/components/306-commit-input.md b/docs/components/306-commit-input.md
new file mode 100644
index 0000000..4676a1a
--- /dev/null
+++ b/docs/components/306-commit-input.md
@@ -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 时组件保持锁定。
+- 不做提交信息格式校验。
diff --git a/docs/components/307-commit-graph.md b/docs/components/307-commit-graph.md
new file mode 100644
index 0000000..82398ea
--- /dev/null
+++ b/docs/components/307-commit-graph.md
@@ -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 时,右侧泳道被裁剪。
diff --git a/docs/components/308-commit-list.md b/docs/components/308-commit-list.md
new file mode 100644
index 0000000..1cc292b
--- /dev/null
+++ b/docs/components/308-commit-list.md
@@ -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 打开。
+
+## 限制
+
+- 时间按调用方给定格式显示,不做相对时间。
diff --git a/docs/components/309-branch-selector.md b/docs/components/309-branch-selector.md
new file mode 100644
index 0000000..e567aec
--- /dev/null
+++ b/docs/components/309-branch-selector.md
@@ -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:选择与新建都由调用方处理。
+- 搜索为大小写不敏感的子串匹配。
diff --git a/docs/components/310-branch-list.md b/docs/components/310-branch-list.md
new file mode 100644
index 0000000..cec3aa1
--- /dev/null
+++ b/docs/components/310-branch-list.md
@@ -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 等组件。
diff --git a/docs/components/311-tag-list.md b/docs/components/311-tag-list.md
new file mode 100644
index 0000000..13d9b89
--- /dev/null
+++ b/docs/components/311-tag-list.md
@@ -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 到达。
+
+## 限制
+
+- 不读取标签:数据由调用方提供。
diff --git a/docs/components/312-stash-list.md b/docs/components/312-stash-list.md
new file mode 100644
index 0000000..f7901bd
--- /dev/null
+++ b/docs/components/312-stash-list.md
@@ -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 执行。
+
+## 限制
+
+- 删除不带确认;需要时由调用方在回调里确认。
diff --git a/docs/components/313-blame-view.md b/docs/components/313-blame-view.md
new file mode 100644
index 0000000..ba5eada
--- /dev/null
+++ b/docs/components/313-blame-view.md
@@ -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 提示,只能用指针触发;每行左栏的名称同样朗读作者与提交。
diff --git a/docs/components/314-file-history.md b/docs/components/314-file-history.md
new file mode 100644
index 0000000..5aab49a
--- /dev/null
+++ b/docs/components/314-file-history.md
@@ -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 到达。
+
+## 限制
+
+- 不跟踪重命名:提交列表由调用方提供。
diff --git a/docs/components/315-pull-request-card.md b/docs/components/315-pull-request-card.md
new file mode 100644
index 0000000..a5ad61a
--- /dev/null
+++ b/docs/components/315-pull-request-card.md
@@ -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 打开。
+
+## 限制
+
+- 数据由调用方获取;组件不刷新检查状态。
diff --git a/docs/components/316-review-comment.md b/docs/components/316-review-comment.md
new file mode 100644
index 0000000..6ed4b9a
--- /dev/null
+++ b/docs/components/316-review-comment.md
@@ -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。
+- 线程状态由调用方更新。
diff --git a/docs/components/317-diff-stat.md b/docs/components/317-diff-stat.md
new file mode 100644
index 0000000..fc4143b
--- /dev/null
+++ b/docs/components/317-diff-stat.md
@@ -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 行”。
+
+## 事件
+
+- 无。
+
+## 键盘操作
+
+- 无:纯展示,不获取焦点。
+
+## 限制
+
+- 无。
diff --git a/docs/components/318-git-status-badge.md b/docs/components/318-git-status-badge.md
new file mode 100644
index 0000000..c4f7ac1
--- /dev/null
+++ b/docs/components/318-git-status-badge.md
@@ -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。
+- 名称写入朗读文本与提示,不只靠颜色。
+
+## 事件
+
+- 无。
+
+## 键盘操作
+
+- 无:纯展示。
+
+## 限制
+
+- 无。
diff --git a/docs/components/319-command-block.md b/docs/components/319-command-block.md
new file mode 100644
index 0000000..ee28831
--- /dev/null
+++ b/docs/components/319-command-block.md
@@ -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 到达。
+
+## 限制
+
+- 不运行命令:输出与状态由调用方提供。
diff --git a/docs/components/320-ansi-text.md b/docs/components/320-ansi-text.md
new file mode 100644
index 0000000..a5255f7
--- /dev/null
+++ b/docs/components/320-ansi-text.md
@@ -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 色与真彩不做对比度调整。
diff --git a/example_git_test.go b/example_git_test.go
new file mode 100644
index 0000000..51e60e0
--- /dev/null
+++ b/example_git_test.go
@@ -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)
+}
diff --git a/examples/gallery/pages_git.go b/examples/gallery/pages_git.go
index 103b0e7..4ac34b8 100644
--- a/examples/gallery/pages_git.go
+++ b/examples/gallery/pages_git.go
@@ -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.",
+ },
+}
diff --git a/examples/gallery/pages_git_cards.go b/examples/gallery/pages_git_cards.go
new file mode 100644
index 0000000..3f6dc03
--- /dev/null
+++ b/examples/gallery/pages_git_cards.go
@@ -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})
+ })
+ })
+}
diff --git a/examples/gallery/pages_git_demos.go b/examples/gallery/pages_git_demos.go
new file mode 100644
index 0000000..8ad4b9b
--- /dev/null
+++ b/examples/gallery/pages_git_demos.go
@@ -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])
+ }
+}
diff --git a/file_history.go b/file_history.go
new file mode 100644
index 0000000..d13a1fb
--- /dev/null
+++ b/file_history.go
@@ -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
+}
diff --git a/file_history_test.go b/file_history_test.go
new file mode 100644
index 0000000..21fcbac
--- /dev/null
+++ b/file_history_test.go
@@ -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)
+ })
+ })
+}
diff --git a/git_status_badge.go b/git_status_badge.go
new file mode 100644
index 0000000..b9b44a4
--- /dev/null
+++ b/git_status_badge.go
@@ -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
+}
diff --git a/git_status_badge_test.go b/git_status_badge_test.go
new file mode 100644
index 0000000..2888fdc
--- /dev/null
+++ b/git_status_badge_test.go
@@ -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})
+ }
+ })
+ })
+ })
+}
diff --git a/icons/svg/archive.svg b/icons/svg/archive.svg
new file mode 100644
index 0000000..47823fa
--- /dev/null
+++ b/icons/svg/archive.svg
@@ -0,0 +1,16 @@
+
diff --git a/icons/svg/arrow-left-right.svg b/icons/svg/arrow-left-right.svg
new file mode 100644
index 0000000..1b0e8bd
--- /dev/null
+++ b/icons/svg/arrow-left-right.svg
@@ -0,0 +1,17 @@
+
diff --git a/icons/svg/chevrons-up-down.svg b/icons/svg/chevrons-up-down.svg
new file mode 100644
index 0000000..629ee86
--- /dev/null
+++ b/icons/svg/chevrons-up-down.svg
@@ -0,0 +1,15 @@
+
diff --git a/icons/svg/git-branch.svg b/icons/svg/git-branch.svg
new file mode 100644
index 0000000..85b3f96
--- /dev/null
+++ b/icons/svg/git-branch.svg
@@ -0,0 +1,16 @@
+
diff --git a/icons/svg/git-commit-horizontal.svg b/icons/svg/git-commit-horizontal.svg
new file mode 100644
index 0000000..5ee4eb5
--- /dev/null
+++ b/icons/svg/git-commit-horizontal.svg
@@ -0,0 +1,16 @@
+
diff --git a/icons/svg/git-merge.svg b/icons/svg/git-merge.svg
new file mode 100644
index 0000000..dc901a4
--- /dev/null
+++ b/icons/svg/git-merge.svg
@@ -0,0 +1,16 @@
+
diff --git a/icons/svg/git-pull-request.svg b/icons/svg/git-pull-request.svg
new file mode 100644
index 0000000..d2540a1
--- /dev/null
+++ b/icons/svg/git-pull-request.svg
@@ -0,0 +1,17 @@
+
diff --git a/icons/svg/history.svg b/icons/svg/history.svg
new file mode 100644
index 0000000..02ac266
--- /dev/null
+++ b/icons/svg/history.svg
@@ -0,0 +1,16 @@
+
diff --git a/icons/svg/message-square.svg b/icons/svg/message-square.svg
new file mode 100644
index 0000000..4908056
--- /dev/null
+++ b/icons/svg/message-square.svg
@@ -0,0 +1,14 @@
+
diff --git a/icons/svg/reply.svg b/icons/svg/reply.svg
new file mode 100644
index 0000000..eb1eb3f
--- /dev/null
+++ b/icons/svg/reply.svg
@@ -0,0 +1,15 @@
+
diff --git a/icons/svg/rotate-ccw.svg b/icons/svg/rotate-ccw.svg
new file mode 100644
index 0000000..1d22d4a
--- /dev/null
+++ b/icons/svg/rotate-ccw.svg
@@ -0,0 +1,15 @@
+
diff --git a/icons/svg/tag.svg b/icons/svg/tag.svg
new file mode 100644
index 0000000..954f3f2
--- /dev/null
+++ b/icons/svg/tag.svg
@@ -0,0 +1,15 @@
+
diff --git a/inline_diff.go b/inline_diff.go
new file mode 100644
index 0000000..bcbca60
--- /dev/null
+++ b/inline_diff.go
@@ -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)
+ })
+}
diff --git a/inline_diff_test.go b/inline_diff_test.go
new file mode 100644
index 0000000..1f78728
--- /dev/null
+++ b/inline_diff_test.go
@@ -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})
+ })
+ })
+}
diff --git a/internal/diff.go b/internal/diff.go
index 332c68f..01dda62 100644
--- a/internal/diff.go
+++ b/internal/diff.go
@@ -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 {
diff --git a/internal/diff_word_test.go b/internal/diff_word_test.go
new file mode 100644
index 0000000..29bdb1d
--- /dev/null
+++ b/internal/diff_word_test.go
@@ -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))
+ }
+ }
+}
diff --git a/internal/git.go b/internal/git.go
new file mode 100644
index 0000000..6d0f166
--- /dev/null
+++ b/internal/git.go
@@ -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()
+}
diff --git a/internal/git_test.go b/internal/git_test.go
new file mode 100644
index 0000000..d5831b8
--- /dev/null
+++ b/internal/git_test.go
@@ -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)
+ }
+}
diff --git a/messages_git.go b/messages_git.go
new file mode 100644
index 0000000..445a4cd
--- /dev/null
+++ b/messages_git.go
@@ -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", "修订"},
+ })
+}
diff --git a/pull_request_card.go b/pull_request_card.go
new file mode 100644
index 0000000..e51706b
--- /dev/null
+++ b/pull_request_card.go
@@ -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) })
+ })
+}
diff --git a/pull_request_card_test.go b/pull_request_card_test.go
new file mode 100644
index 0000000..caa4960
--- /dev/null
+++ b/pull_request_card_test.go
@@ -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{})
+ })
+ })
+}
diff --git a/review_comment.go b/review_comment.go
new file mode 100644
index 0000000..5731307
--- /dev/null
+++ b/review_comment.go
@@ -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
+}
diff --git a/review_comment_test.go b/review_comment_test.go
new file mode 100644
index 0000000..f2b8711
--- /dev/null
+++ b/review_comment_test.go
@@ -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{})
+ })
+ })
+}
diff --git a/stash_list.go b/stash_list.go
new file mode 100644
index 0000000..f505ca0
--- /dev/null
+++ b/stash_list.go
@@ -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
+}
diff --git a/stash_list_test.go b/stash_list_test.go
new file mode 100644
index 0000000..fd76dd0
--- /dev/null
+++ b/stash_list_test.go
@@ -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)
+ })
+ })
+}
diff --git a/tag_list.go b/tag_list.go
new file mode 100644
index 0000000..7e14756
--- /dev/null
+++ b/tag_list.go
@@ -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
+}
diff --git a/tag_list_test.go b/tag_list_test.go
new file mode 100644
index 0000000..5f4ada8
--- /dev/null
+++ b/tag_list_test.go
@@ -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)
+ })
+ })
+}
diff --git a/testdata/snapshots/darwin/ansi_text-dark.png b/testdata/snapshots/darwin/ansi_text-dark.png
new file mode 100644
index 0000000..0f36ef0
Binary files /dev/null and b/testdata/snapshots/darwin/ansi_text-dark.png differ
diff --git a/testdata/snapshots/darwin/ansi_text-light.png b/testdata/snapshots/darwin/ansi_text-light.png
new file mode 100644
index 0000000..a6c5d94
Binary files /dev/null and b/testdata/snapshots/darwin/ansi_text-light.png differ
diff --git a/testdata/snapshots/darwin/blame_view-dark.png b/testdata/snapshots/darwin/blame_view-dark.png
new file mode 100644
index 0000000..70f6090
Binary files /dev/null and b/testdata/snapshots/darwin/blame_view-dark.png differ
diff --git a/testdata/snapshots/darwin/blame_view-light.png b/testdata/snapshots/darwin/blame_view-light.png
new file mode 100644
index 0000000..5c64377
Binary files /dev/null and b/testdata/snapshots/darwin/blame_view-light.png differ
diff --git a/testdata/snapshots/darwin/branch_list-dark.png b/testdata/snapshots/darwin/branch_list-dark.png
new file mode 100644
index 0000000..3b39735
Binary files /dev/null and b/testdata/snapshots/darwin/branch_list-dark.png differ
diff --git a/testdata/snapshots/darwin/branch_list-light.png b/testdata/snapshots/darwin/branch_list-light.png
new file mode 100644
index 0000000..3019690
Binary files /dev/null and b/testdata/snapshots/darwin/branch_list-light.png differ
diff --git a/testdata/snapshots/darwin/branch_selector-dark.png b/testdata/snapshots/darwin/branch_selector-dark.png
new file mode 100644
index 0000000..46a00c6
Binary files /dev/null and b/testdata/snapshots/darwin/branch_selector-dark.png differ
diff --git a/testdata/snapshots/darwin/branch_selector-light.png b/testdata/snapshots/darwin/branch_selector-light.png
new file mode 100644
index 0000000..bd8ae7c
Binary files /dev/null and b/testdata/snapshots/darwin/branch_selector-light.png differ
diff --git a/testdata/snapshots/darwin/changes_list-dark.png b/testdata/snapshots/darwin/changes_list-dark.png
new file mode 100644
index 0000000..4225ab6
Binary files /dev/null and b/testdata/snapshots/darwin/changes_list-dark.png differ
diff --git a/testdata/snapshots/darwin/changes_list-light.png b/testdata/snapshots/darwin/changes_list-light.png
new file mode 100644
index 0000000..e796485
Binary files /dev/null and b/testdata/snapshots/darwin/changes_list-light.png differ
diff --git a/testdata/snapshots/darwin/command_block-dark.png b/testdata/snapshots/darwin/command_block-dark.png
new file mode 100644
index 0000000..892e54a
Binary files /dev/null and b/testdata/snapshots/darwin/command_block-dark.png differ
diff --git a/testdata/snapshots/darwin/command_block-light.png b/testdata/snapshots/darwin/command_block-light.png
new file mode 100644
index 0000000..028584b
Binary files /dev/null and b/testdata/snapshots/darwin/command_block-light.png differ
diff --git a/testdata/snapshots/darwin/commit_graph-dark.png b/testdata/snapshots/darwin/commit_graph-dark.png
new file mode 100644
index 0000000..902eb32
Binary files /dev/null and b/testdata/snapshots/darwin/commit_graph-dark.png differ
diff --git a/testdata/snapshots/darwin/commit_graph-light.png b/testdata/snapshots/darwin/commit_graph-light.png
new file mode 100644
index 0000000..6344bcc
Binary files /dev/null and b/testdata/snapshots/darwin/commit_graph-light.png differ
diff --git a/testdata/snapshots/darwin/commit_input-dark.png b/testdata/snapshots/darwin/commit_input-dark.png
new file mode 100644
index 0000000..a83bb54
Binary files /dev/null and b/testdata/snapshots/darwin/commit_input-dark.png differ
diff --git a/testdata/snapshots/darwin/commit_input-light.png b/testdata/snapshots/darwin/commit_input-light.png
new file mode 100644
index 0000000..99b1d49
Binary files /dev/null and b/testdata/snapshots/darwin/commit_input-light.png differ
diff --git a/testdata/snapshots/darwin/commit_list-dark.png b/testdata/snapshots/darwin/commit_list-dark.png
new file mode 100644
index 0000000..08d404f
Binary files /dev/null and b/testdata/snapshots/darwin/commit_list-dark.png differ
diff --git a/testdata/snapshots/darwin/commit_list-light.png b/testdata/snapshots/darwin/commit_list-light.png
new file mode 100644
index 0000000..78f01a3
Binary files /dev/null and b/testdata/snapshots/darwin/commit_list-light.png differ
diff --git a/testdata/snapshots/darwin/conflict_resolver-dark.png b/testdata/snapshots/darwin/conflict_resolver-dark.png
new file mode 100644
index 0000000..704d851
Binary files /dev/null and b/testdata/snapshots/darwin/conflict_resolver-dark.png differ
diff --git a/testdata/snapshots/darwin/conflict_resolver-light.png b/testdata/snapshots/darwin/conflict_resolver-light.png
new file mode 100644
index 0000000..271f745
Binary files /dev/null and b/testdata/snapshots/darwin/conflict_resolver-light.png differ
diff --git a/testdata/snapshots/darwin/diff_stat-dark.png b/testdata/snapshots/darwin/diff_stat-dark.png
new file mode 100644
index 0000000..0b39984
Binary files /dev/null and b/testdata/snapshots/darwin/diff_stat-dark.png differ
diff --git a/testdata/snapshots/darwin/diff_stat-light.png b/testdata/snapshots/darwin/diff_stat-light.png
new file mode 100644
index 0000000..93e0b93
Binary files /dev/null and b/testdata/snapshots/darwin/diff_stat-light.png differ
diff --git a/testdata/snapshots/darwin/diff_viewer-dark.png b/testdata/snapshots/darwin/diff_viewer-dark.png
new file mode 100644
index 0000000..96b226d
Binary files /dev/null and b/testdata/snapshots/darwin/diff_viewer-dark.png differ
diff --git a/testdata/snapshots/darwin/diff_viewer-light.png b/testdata/snapshots/darwin/diff_viewer-light.png
new file mode 100644
index 0000000..23ecef1
Binary files /dev/null and b/testdata/snapshots/darwin/diff_viewer-light.png differ
diff --git a/testdata/snapshots/darwin/file_history-dark.png b/testdata/snapshots/darwin/file_history-dark.png
new file mode 100644
index 0000000..6ad872e
Binary files /dev/null and b/testdata/snapshots/darwin/file_history-dark.png differ
diff --git a/testdata/snapshots/darwin/file_history-light.png b/testdata/snapshots/darwin/file_history-light.png
new file mode 100644
index 0000000..733704f
Binary files /dev/null and b/testdata/snapshots/darwin/file_history-light.png differ
diff --git a/testdata/snapshots/darwin/git_status_badge-dark.png b/testdata/snapshots/darwin/git_status_badge-dark.png
new file mode 100644
index 0000000..7ef1ce6
Binary files /dev/null and b/testdata/snapshots/darwin/git_status_badge-dark.png differ
diff --git a/testdata/snapshots/darwin/git_status_badge-light.png b/testdata/snapshots/darwin/git_status_badge-light.png
new file mode 100644
index 0000000..6ecbc24
Binary files /dev/null and b/testdata/snapshots/darwin/git_status_badge-light.png differ
diff --git a/testdata/snapshots/darwin/inline_diff-dark.png b/testdata/snapshots/darwin/inline_diff-dark.png
new file mode 100644
index 0000000..da868df
Binary files /dev/null and b/testdata/snapshots/darwin/inline_diff-dark.png differ
diff --git a/testdata/snapshots/darwin/inline_diff-light.png b/testdata/snapshots/darwin/inline_diff-light.png
new file mode 100644
index 0000000..ca6f3a2
Binary files /dev/null and b/testdata/snapshots/darwin/inline_diff-light.png differ
diff --git a/testdata/snapshots/darwin/pull_request_card-dark.png b/testdata/snapshots/darwin/pull_request_card-dark.png
new file mode 100644
index 0000000..353a57b
Binary files /dev/null and b/testdata/snapshots/darwin/pull_request_card-dark.png differ
diff --git a/testdata/snapshots/darwin/pull_request_card-light.png b/testdata/snapshots/darwin/pull_request_card-light.png
new file mode 100644
index 0000000..987ba23
Binary files /dev/null and b/testdata/snapshots/darwin/pull_request_card-light.png differ
diff --git a/testdata/snapshots/darwin/review_comment-dark.png b/testdata/snapshots/darwin/review_comment-dark.png
new file mode 100644
index 0000000..508238e
Binary files /dev/null and b/testdata/snapshots/darwin/review_comment-dark.png differ
diff --git a/testdata/snapshots/darwin/review_comment-light.png b/testdata/snapshots/darwin/review_comment-light.png
new file mode 100644
index 0000000..67db2b5
Binary files /dev/null and b/testdata/snapshots/darwin/review_comment-light.png differ
diff --git a/testdata/snapshots/darwin/stash_list-dark.png b/testdata/snapshots/darwin/stash_list-dark.png
new file mode 100644
index 0000000..4a36fdd
Binary files /dev/null and b/testdata/snapshots/darwin/stash_list-dark.png differ
diff --git a/testdata/snapshots/darwin/stash_list-light.png b/testdata/snapshots/darwin/stash_list-light.png
new file mode 100644
index 0000000..6bb21d6
Binary files /dev/null and b/testdata/snapshots/darwin/stash_list-light.png differ
diff --git a/testdata/snapshots/darwin/tag_list-dark.png b/testdata/snapshots/darwin/tag_list-dark.png
new file mode 100644
index 0000000..1a9535c
Binary files /dev/null and b/testdata/snapshots/darwin/tag_list-dark.png differ
diff --git a/testdata/snapshots/darwin/tag_list-light.png b/testdata/snapshots/darwin/tag_list-light.png
new file mode 100644
index 0000000..da5953d
Binary files /dev/null and b/testdata/snapshots/darwin/tag_list-light.png differ
diff --git a/testdata/snapshots/darwin/three_way_merge-dark.png b/testdata/snapshots/darwin/three_way_merge-dark.png
new file mode 100644
index 0000000..a18d990
Binary files /dev/null and b/testdata/snapshots/darwin/three_way_merge-dark.png differ
diff --git a/testdata/snapshots/darwin/three_way_merge-light.png b/testdata/snapshots/darwin/three_way_merge-light.png
new file mode 100644
index 0000000..8dfcc51
Binary files /dev/null and b/testdata/snapshots/darwin/three_way_merge-light.png differ
diff --git a/three_way_merge.go b/three_way_merge.go
new file mode 100644
index 0000000..7a7ab59
--- /dev/null
+++ b/three_way_merge.go
@@ -0,0 +1,327 @@
+package mujica
+
+import (
+ "fmt"
+ "log/slog"
+ "slices"
+ "strings"
+
+ "github.com/egoist/mygo/ui"
+
+ "mujicaui/icons"
+ "mujicaui/internal"
+ "mujicaui/theme"
+)
+
+// MergeChoice is how a conflict is resolved.
+type MergeChoice int
+
+const (
+ MergeUnresolved MergeChoice = iota
+ // MergeOurs takes the local (current) side.
+ MergeOurs
+ // MergeTheirs takes the remote (incoming) side.
+ MergeTheirs
+ // MergeBoth takes ours, then theirs.
+ MergeBoth
+ // MergeBase keeps the common ancestor.
+ MergeBase
+)
+
+// mergeChunk is a merge run; stable when all three agree.
+type mergeChunk struct {
+ base, ours, theirs []string
+ conflict bool
+ // auto is the merged text of a non-conflicting change.
+ auto []string
+ stable bool
+}
+
+// ThreeWayMergeState holds a merge's chunks and conflict choices.
+type ThreeWayMergeState struct {
+ list ui.ListState
+ base, ours, theirs string
+ chunks []mergeChunk
+ choices []MergeChoice
+ built bool
+}
+
+// Resolved returns chosen conflicts and all conflicts.
+func (s *ThreeWayMergeState) Resolved() (done, total int) {
+ for i, ch := range s.chunks {
+ if ch.conflict {
+ total++
+ if s.choices[i] != MergeUnresolved {
+ done++
+ }
+ }
+ }
+ return done, total
+}
+
+// Result is the merged text; unresolved conflicts keep conflict markers.
+func (s *ThreeWayMergeState) Result() string {
+ var out []string
+ for i, ch := range s.chunks {
+ out = append(out, mergeChunkText(ch, s.choices[i])...)
+ }
+ return strings.Join(out, "\n")
+}
+
+func mergeChunkText(ch mergeChunk, choice MergeChoice) []string {
+ switch {
+ case ch.stable:
+ return ch.base
+ case !ch.conflict:
+ return ch.auto
+ }
+ switch choice {
+ case MergeOurs:
+ return ch.ours
+ case MergeTheirs:
+ return ch.theirs
+ case MergeBoth:
+ return slices.Concat(ch.ours, ch.theirs)
+ case MergeBase:
+ return ch.base
+ }
+ return slices.Concat([]string{"<<<<<<< ours"}, ch.ours, []string{"======="}, ch.theirs, []string{">>>>>>> theirs"})
+}
+
+// ThreeWayMergeOptions configure a ThreeWayMerge.
+type ThreeWayMergeOptions struct {
+ Disabled bool
+}
+
+// ThreeWayMerge shows four columns per chunk; give it a height.
+func ThreeWayMerge(c *ui.Context, s *ThreeWayMergeState, base, ours, theirs string, opts ThreeWayMergeOptions) ChoiceResult {
+ if !s.built || s.base != base || s.ours != ours || s.theirs != theirs {
+ s.chunks = mergeChunks(internal.SplitLines(base), internal.SplitLines(ours), internal.SplitLines(theirs))
+ s.choices = make([]MergeChoice, len(s.chunks))
+ s.base, s.ours, s.theirs, s.built = base, ours, theirs, true
+ _, total := s.Resolved()
+ slog.Debug("mujica: three way merge chunks", "chunks", len(s.chunks), "conflicts", total)
+ }
+ k := Tokens(c)
+ u := Density(c).Unit()
+ col := ui.Column(c).AlignItems(ui.Stretch).Disabled(opts.Disabled).Background(k.Surface).
+ Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius).Clip()
+ res := ChoiceResult{Element: col}
+ col.Children(func() {
+ done, total := s.Resolved()
+ ui.Row(c).AlignItems(ui.Center).Gap(u*3).Padding(u*2, u*3).Background(k.Background).Children(func() {
+ ui.Text(c, fmt.Sprintf(msg(c, "three_way_merge.resolved"), done, total)).FontWeight(600).Role(ui.RoleStatus)
+ })
+ ui.Grid(c).ColumnTracks(ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1)).Background(k.Background).Children(func() {
+ for _, key := range []string{"ours", "base", "theirs", "result"} {
+ ui.Text(c, msg(c, "three_way_merge."+key)).Font(theme.SerifFont).FontSize(FontSize(c, theme.CaptionSize)).
+ TextColor(k.Ornament.Mix(k.Text, 0.35)).Padding(u, u*3)
+ }
+ })
+ ui.List(c, &s.list, len(s.chunks), func(i int) {
+ if threeWayMergeChunk(c, s, i) && !col.IsDisabled() {
+ res.changed = true
+ }
+ }).Grow(1)
+ })
+ return res
+}
+
+// threeWayMergeChunk builds chunk i and reports a new choice.
+func threeWayMergeChunk(c *ui.Context, s *ThreeWayMergeState, i int) bool {
+ k := Tokens(c)
+ u := Density(c).Unit()
+ ch := s.chunks[i]
+ changed := false
+ ui.Column(c).AlignItems(ui.Stretch).Children(func() {
+ grid := ui.Grid(c).ColumnTracks(ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1))
+ grid.Draw(func(p *ui.Painter, r ui.Rect) { p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W, H: 1}, k.Border, 0) })
+ grid.Children(func() {
+ tint := func(lines []string, col ui.Color, on bool) {
+ cell := ui.Column(c).Padding(u, u*3).MinWidth(0).Clip()
+ if on {
+ cell.Background(col.Mix(k.Surface, 0.88))
+ }
+ cell.Children(func() { mergeLines(c, lines, ch.stable) })
+ }
+ if ch.stable {
+ tint(ch.base, k.Text, false)
+ tint(ch.base, k.Text, false)
+ tint(ch.base, k.Text, false)
+ tint(ch.base, k.Text, false)
+ return
+ }
+ choice := s.choices[i]
+ tint(ch.ours, k.Info, ch.conflict && (choice == MergeOurs || choice == MergeBoth) || !ch.conflict && slices.Equal(ch.auto, ch.ours))
+ tint(ch.base, k.TextMuted, choice == MergeBase)
+ tint(ch.theirs, k.Warning, ch.conflict && (choice == MergeTheirs || choice == MergeBoth) || !ch.conflict && slices.Equal(ch.auto, ch.theirs))
+ result := ui.Column(c).Padding(u, u*3).MinWidth(0).Clip()
+ result.Children(func() {
+ if ch.conflict && choice == MergeUnresolved {
+ ui.Row(c).Gap(u).AlignItems(ui.Center).TextColor(k.Danger).Children(func() {
+ ui.Icon(c, icons.Must("triangle-alert")).Size(14, 14)
+ ui.Text(c, msg(c, "three_way_merge.unresolved")).FontSize(FontSize(c, theme.CaptionSize))
+ })
+ return
+ }
+ mergeLines(c, mergeChunkText(ch, choice), false)
+ })
+ })
+ if !ch.conflict {
+ return
+ }
+ ui.Row(c).Gap(u*2).Padding(u, u*3, u*2, u*3).Wrap().Children(func() {
+ for _, opt := range []struct {
+ choice MergeChoice
+ key string
+ }{{MergeOurs, "take_ours"}, {MergeTheirs, "take_theirs"}, {MergeBoth, "take_both"}, {MergeBase, "take_base"}} {
+ if mergeChoiceButton(c, msg(c, "three_way_merge."+opt.key), s.choices[i] == opt.choice).Clicked() && s.choices[i] != opt.choice {
+ s.choices[i], changed = opt.choice, true
+ slog.Debug("mujica: three way merge choose", "chunk", i, "choice", opt.key)
+ }
+ }
+ })
+ })
+ return changed
+}
+
+// mergeChoiceButton is a toggle marking the chosen side.
+func mergeChoiceButton(c *ui.Context, label string, on bool) *ui.Element {
+ k := Tokens(c)
+ u := Density(c).Unit()
+ b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).Padding(0, u*3).Gap(u*1.5).Radius(theme.ControlRadius).
+ Border(theme.BorderWidth, k.ControlBorder).Label(label).TextColor(k.Text)
+ internal.Ring(b, k.Focus, theme.ControlRadius)
+ switch {
+ case on:
+ b.Background(k.Selection).Border(theme.BorderWidth, k.AccentText).FontWeight(600)
+ case b.Pressed():
+ b.Background(k.Selection)
+ case b.Hovered():
+ b.Background(k.SurfaceHover)
+ }
+ b.Transition(colorFade)
+ b.Children(func() {
+ if on {
+ ui.Icon(c, icons.Must("check")).Size(14, 14).TextColor(k.AccentText)
+ }
+ ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize)).SingleLine()
+ })
+ return b
+}
+
+// mergeLines shows code lines, folding long stable runs.
+func mergeLines(c *ui.Context, lines []string, fold bool) {
+ k := Tokens(c)
+ show := func(ls []string) {
+ for _, l := range ls {
+ if l == "" {
+ l = " "
+ }
+ t := ui.Text(c, l).Font(theme.MonoFont).NoWrap().FontSize(FontSize(c, theme.CaptionSize))
+ if fold {
+ t.TextColor(k.TextMuted)
+ }
+ }
+ }
+ if len(lines) == 0 {
+ ui.Text(c, "∅").TextColor(k.TextMuted).Label(msg(c, "three_way_merge.empty"))
+ return
+ }
+ if !fold || len(lines) <= 5 {
+ show(lines)
+ return
+ }
+ show(lines[:2])
+ ui.Text(c, fmt.Sprintf(msg(c, "diff_viewer.fold"), len(lines)-4)).Font(theme.SerifFont).
+ FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
+ show(lines[len(lines)-2:])
+}
+
+// mergeHunk replaces base lines [from, to) with lines.
+type mergeHunk struct {
+ from, to int
+ lines []string
+}
+
+// mergeHunks lists the changes a diff makes to base.
+func mergeHunks(d []internal.DiffLine) []mergeHunk {
+ var out []mergeHunk
+ at, open := 0, -1
+ for _, l := range d {
+ if l.Op == internal.DiffEqual {
+ at = l.Old
+ open = -1
+ continue
+ }
+ if open < 0 {
+ out = append(out, mergeHunk{from: at, to: at})
+ open = len(out) - 1
+ }
+ h := &out[open]
+ if l.Op == internal.DiffDelete {
+ h.to = l.Old
+ } else {
+ h.lines = append(h.lines, l.Text)
+ }
+ }
+ return out
+}
+
+// mergeChunks splits a merge into stable, clean and conflicting runs.
+func mergeChunks(base, ours, theirs []string) []mergeChunk {
+ type tagged struct {
+ mergeHunk
+ side int
+ }
+ var all []tagged
+ for side, other := range [][]string{ours, theirs} {
+ for _, h := range mergeHunks(internal.LineDiff(base, other)) {
+ all = append(all, tagged{h, side})
+ }
+ }
+ slices.SortStableFunc(all, func(a, b tagged) int { return a.from - b.from })
+ var chunks []mergeChunk
+ at := 0
+ for i := 0; i < len(all); {
+ from, to := all[i].from, all[i].to
+ j := i + 1
+ for j < len(all) && (all[j].from < to || all[j].from == to && (all[j].from == all[j].to || from == to)) {
+ to = max(to, all[j].to)
+ j++
+ }
+ if from > at {
+ chunks = append(chunks, mergeChunk{base: base[at:from], stable: true})
+ }
+ sides := [2][]string{}
+ touched := [2]bool{}
+ for s := range 2 {
+ cur := from
+ for _, h := range all[i:j] {
+ if h.side != s {
+ continue
+ }
+ touched[s] = true
+ sides[s] = append(sides[s], base[cur:h.from]...)
+ sides[s] = append(sides[s], h.lines...)
+ cur = h.to
+ }
+ sides[s] = append(sides[s], base[cur:to]...)
+ }
+ ch := mergeChunk{base: base[from:to], ours: sides[0], theirs: sides[1]}
+ switch {
+ case !touched[1]:
+ ch.auto = ch.ours
+ case !touched[0], slices.Equal(ch.ours, ch.theirs):
+ ch.auto = ch.theirs
+ default:
+ ch.conflict = true
+ }
+ chunks = append(chunks, ch)
+ at, i = to, j
+ }
+ if at < len(base) {
+ chunks = append(chunks, mergeChunk{base: base[at:], stable: true})
+ }
+ return chunks
+}
diff --git a/three_way_merge_test.go b/three_way_merge_test.go
new file mode 100644
index 0000000..87e7551
--- /dev/null
+++ b/three_way_merge_test.go
@@ -0,0 +1,80 @@
+package mujica
+
+import (
+ "testing"
+
+ "github.com/egoist/mygo/ui"
+)
+
+const (
+ mergeBase = "a\nb\nc\nd\ne\nf"
+ mergeOurs = "a\nB1\nc\nd\ne\nf\ng"
+ mergeTheirs = "a\nB2\nc\nD\ne\nf"
+)
+
+func TestMergeChunks(t *testing.T) {
+ chunks := mergeChunks([]string{"a", "b", "c", "d", "e", "f"}, []string{"a", "B1", "c", "d", "e", "f", "g"}, []string{"a", "B2", "c", "D", "e", "f"})
+ // a | b conflict | c | d→D clean | e f | +g clean
+ if len(chunks) != 6 {
+ t.Fatalf("%d chunks: %+v", len(chunks), chunks)
+ }
+ if !chunks[1].conflict || chunks[1].ours[0] != "B1" || chunks[1].theirs[0] != "B2" {
+ t.Errorf("conflict chunk %+v", chunks[1])
+ }
+ if chunks[3].conflict || chunks[3].auto[0] != "D" {
+ t.Errorf("clean remote change %+v", chunks[3])
+ }
+ if chunks[5].conflict || len(chunks[5].auto) != 1 || chunks[5].auto[0] != "g" {
+ t.Errorf("clean local append %+v", chunks[5])
+ }
+ same := mergeChunks([]string{"x"}, []string{"y"}, []string{"y"})
+ if len(same) != 1 || same[0].conflict {
+ t.Errorf("identical changes conflict: %+v", same)
+ }
+}
+
+func TestThreeWayMergeTakeSides(t *testing.T) {
+ var s ThreeWayMergeState
+ changes := 0
+ disabled := false
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ r := ThreeWayMerge(c, &s, mergeBase, mergeOurs, mergeTheirs, ThreeWayMergeOptions{Disabled: disabled})
+ r.Element.Height(560)
+ if r.Changed() {
+ changes++
+ }
+ }, 900, 600)
+ if !tt.HasText("0 of 1 conflicts resolved") || !tt.HasText("Unresolved") {
+ t.Fatalf("start: %q", tt.Texts())
+ }
+ if got := s.Result(); got != "a\n<<<<<<< ours\nB1\n=======\nB2\n>>>>>>> theirs\nc\nD\ne\nf\ng" {
+ t.Fatalf("unresolved result %q", got)
+ }
+ if err := tt.Click("Take remote"); err != nil {
+ t.Fatal(err)
+ }
+ if s.Result() != "a\nB2\nc\nD\ne\nf\ng" || changes != 1 || !tt.HasText("1 of 1 conflicts resolved") {
+ t.Fatalf("take remote: %q changes %d", s.Result(), changes)
+ }
+ tt.Key(0, ui.KeyTab)
+ _ = tt.Click("Take both")
+ if s.Result() != "a\nB1\nB2\nc\nD\ne\nf\ng" {
+ t.Fatalf("take both: %q", s.Result())
+ }
+ disabled = true
+ tt.Frame()
+ _ = tt.Click("Keep base")
+ if s.Result() != "a\nB1\nB2\nc\nD\ne\nf\ng" || changes != 2 {
+ t.Fatalf("disabled merge changed: %q", s.Result())
+ }
+}
+
+func TestThreeWayMergeSnapshot(t *testing.T) {
+ snapshot(t, "three_way_merge", 860, 420, func(c *ui.Context) {
+ s := ui.Local(c.Root(), "m", func() *ThreeWayMergeState { return &ThreeWayMergeState{} })
+ ui.Column(c).Padding(16).Children(func() {
+ ThreeWayMerge(c, *s, mergeBase, mergeOurs, mergeTheirs, ThreeWayMergeOptions{}).Element.Height(380)
+ })
+ })
+}