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) + }) + }) +}