Commit b19c86712e

b19c86712e3effe2ffe5f34969b9efb93965e8c5

parent: 4099abc8b9

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-02 03:14 UTC

control: action legend; here= names the inferred repository

Ref #317

Layout: unified · split

internal/control/screen.go +78 −2
@@ -198,6 +198,82 @@ func (c *Ctx) renderSection(s section) string {
198198 return b.String()
199199}
200200
201func (c *Ctx) renderLegend(as []action) string { return "" }
201// cmdline is argv as the viewer would type it: "gitbay", the words
202// shell-quoted, and the repository the CLI inferred left out.
203func (c *Ctx) cmdline(argv []string) string {
204 words := []string{"gitbay"}
205 for _, a := range argv {
206 if c.Term.Here != "" && a == c.Term.Here {
207 continue
208 }
209 words = append(words, shellWord(a))
210 }
211 return strings.Join(words, " ")
212}
202213
203func (c *Ctx) cmdline(argv []string) string { return "gitbay " + strings.Join(argv, " ") }
214// renderLegend is a rule, then the action groups in the order they first
215// appear, each a bold name over its commands in blue. Groups sit side by
216// side, three to a band, when the terminal is 80 wide or more and the
217// band fits; otherwise they stack.
218func (c *Ctx) renderLegend(as []action) string {
219 if len(as) == 0 {
220 return ""
221 }
222 t := c.Term
223 type group struct {
224 name string
225 cmds []string
226 }
227 var groups []*group
228 byName := map[string]*group{}
229 for _, a := range as {
230 g := byName[a.group]
231 if g == nil {
232 g = &group{name: a.group}
233 byName[a.group] = g
234 groups = append(groups, g)
235 }
236 g.cmds = append(g.cmds, c.cmdline(a.argv))
237 }
238 var b strings.Builder
239 b.WriteString(t.paint(sgrDim, strings.Repeat("─", t.Cols)) + "\n")
240 for start := 0; start < len(groups); start += 3 {
241 band := groups[start:min(start+3, len(groups))]
242 widths := make([]int, len(band))
243 total, height := 2*(len(band)-1), 0
244 for i, g := range band {
245 widths[i] = cells(g.name)
246 for _, cmd := range g.cmds {
247 widths[i] = max(widths[i], cells(cmd))
248 }
249 total += widths[i]
250 height = max(height, len(g.cmds))
251 }
252 if t.Cols < 80 || total > t.Cols {
253 for _, g := range band {
254 b.WriteString(t.paint(sgrBold, g.name) + "\n")
255 for _, cmd := range g.cmds {
256 b.WriteString(t.paint(sgrBlue, cmd) + "\n")
257 }
258 }
259 continue
260 }
261 for line := -1; line < height; line++ {
262 var l strings.Builder
263 for i, g := range band {
264 s, sgr := "", sgrBlue
265 if line < 0 {
266 s, sgr = g.name, sgrBold
267 } else if line < len(g.cmds) {
268 s = g.cmds[line]
269 }
270 l.WriteString(t.paint(sgr, s))
271 if i < len(band)-1 {
272 l.WriteString(strings.Repeat(" ", widths[i]-cells(s)+2))
273 }
274 }
275 b.WriteString(strings.TrimRight(l.String(), " ") + "\n")
276 }
277 }
278 return b.String()
279}
internal/control/screen_test.go +63
@@ -1,6 +1,7 @@
11package control
22
33import (
4 "fmt"
45 "strings"
56 "testing"
67)
@@ -114,3 +115,65 @@ func TestRenderFieldWraps(t *testing.T) {
114115 t.Errorf("continuation not under the value: %q", lines[1])
115116 }
116117}
118
119func TestCmdlineDropsHere(t *testing.T) {
120 c := screenCtx(80, false)
121 c.Term.Here = "krz/gitbay"
122 for _, tc := range []struct {
123 argv []string
124 want string
125 }{
126 {[]string{"mr", "diff", "krz/gitbay", "552"}, "gitbay mr diff 552"},
127 {[]string{"issue", "show", "krz/hutch", "3"}, "gitbay issue show krz/hutch 3"},
128 {[]string{"issue", "list", "--label", "needs review"}, "gitbay issue list --label 'needs review'"},
129 } {
130 if got := c.cmdline(tc.argv); got != tc.want {
131 t.Errorf("cmdline(%q) = %q, want %q", tc.argv, got, tc.want)
132 }
133 }
134}
135
136func legendSample() []action {
137 return []action{
138 {"Unblock", []string{"mr", "rebase", "552"}},
139 {"Review", []string{"mr", "review", "krz/gitbay", "552", "--approve"}},
140 {"Review", []string{"mr", "comment", "krz/gitbay", "552"}},
141 {"Read", []string{"mr", "diff", "krz/gitbay", "552"}},
142 }
143}
144
145func TestLegendColumns(t *testing.T) {
146 c := screenCtx(120, false)
147 c.Term.Here = "krz/gitbay"
148 got := c.renderLegend(legendSample())
149 want := strings.Repeat("─", 120) + "\n" +
150 fmt.Sprintf("%-22s%-32s%s\n", "Unblock", "Review", "Read") +
151 fmt.Sprintf("%-22s%-32s%s\n", "gitbay mr rebase 552", "gitbay mr review 552 --approve", "gitbay mr diff 552") +
152 fmt.Sprintf("%-22s%s\n", "", "gitbay mr comment 552")
153 if got != want {
154 t.Errorf("got:\n%s\nwant:\n%s", got, want)
155 }
156}
157
158func TestLegendStacksWhenNarrow(t *testing.T) {
159 c := screenCtx(60, false)
160 c.Term.Here = "krz/gitbay"
161 got := c.renderLegend(legendSample())
162 want := strings.Repeat("─", 60) + "\n" +
163 "Unblock\ngitbay mr rebase 552\n" +
164 "Review\ngitbay mr review 552 --approve\ngitbay mr comment 552\n" +
165 "Read\ngitbay mr diff 552\n"
166 if got != want {
167 t.Errorf("got:\n%s\nwant:\n%s", got, want)
168 }
169}
170
171func TestLegendColour(t *testing.T) {
172 got := screenCtx(120, true).renderLegend(legendSample())
173 if !strings.Contains(got, sgrBlue+"gitbay mr rebase 552"+sgrReset) || !strings.Contains(got, sgrBold+"Unblock"+sgrReset) {
174 t.Errorf("legend paint: %q", got)
175 }
176 if stripSGR(got) != screenCtx(120, false).renderLegend(legendSample()) {
177 t.Error("colour legend differs beyond SGR")
178 }
179}
internal/control/term.go +7
@@ -22,6 +22,9 @@ type Term struct {
2222 TrueColor bool
2323 // Links is OSC 8 hyperlinks, for a reference's page.
2424 Links bool
25 // Here is the repository the CLI inferred from the clone it runs in,
26 // so a suggested command can leave it out.
27 Here string
2528}
2629
2730// ParseTerm reads "<cols>[,<option>]...". Options it does not know are
@@ -36,6 +39,10 @@ func ParseTerm(v string) Term {
3639 }
3740 t := Term{Cols: n}
3841 for _, opt := range parts[1:] {
42 if v, ok := strings.CutPrefix(opt, "here="); ok {
43 t.Here = v
44 continue
45 }
3946 switch opt {
4047 case "color":
4148 t.Color = true
internal/control/term_test.go +15 −13
@@ -8,19 +8,21 @@ import (
88
99func TestParseTerm(t *testing.T) {
1010 cases := map[string]Term{
11 "120": {Cols: 120},
12 "120,color": {Cols: 120, Color: true},
13 "40": {Cols: 40},
14 "39": {},
15 "": {},
16 "abc": {},
17 "80,blink": {Cols: 80},
18 "80,": {Cols: 80},
19 "80,truecolor,color": {Cols: 80, Color: true, TrueColor: true},
20 "80,color,links": {Cols: 80, Color: true, Links: true},
21 "80,truecolor": {Cols: 80},
22 "abc,color": {},
23 "5000": {},
11 "120": {Cols: 120},
12 "120,color": {Cols: 120, Color: true},
13 "40": {Cols: 40},
14 "39": {},
15 "": {},
16 "abc": {},
17 "80,blink": {Cols: 80},
18 "80,": {Cols: 80},
19 "80,truecolor,color": {Cols: 80, Color: true, TrueColor: true},
20 "80,color,links": {Cols: 80, Color: true, Links: true},
21 "80,truecolor": {Cols: 80},
22 "abc,color": {},
23 "5000": {},
24 "120,color,here=krz/gitbay": {Cols: 120, Color: true, Here: "krz/gitbay"},
25 "120,here=": {Cols: 120},
2426 }
2527 for in, want := range cases {
2628 if got := ParseTerm(in); got != want {