Commit 02a6794816

02a679481694171035dc2d7fac66b32b7d7e2cce

parent: 98ba76cb39

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-24 03:18 UTC

view: sections label sub-tables; full hashes; wiki source when piped

Ref #254
e2e/adminusers_test.go +1 −1
@@ -165,7 +165,7 @@ func TestAdminUserListAndShow(t *testing.T) {
165165 t.Fatalf("unknown user: exit %d", code)
166166 }
167167 // Plain output carries the same facts.
168 if out, _, _ := inst.ssh(t, adminKey, "", "admin", "user", "show", "alice"); !strings.Contains(out, "alice") || !strings.Contains(out, "active") ||
168 if out, _, _ := inst.ssh(t, adminKey, "", "admin", "user", "show", "alice"); !strings.HasPrefix(out, "alice active\n") ||
169169 !strings.Contains(out, "acme\tadmin") || !strings.Contains(out, "verified by admin") {
170170 t.Fatalf("plain show:\n%s", out)
171171 }
e2e/gates_test.go +2 −1
@@ -3,6 +3,7 @@ package e2e
33import (
44 "os"
55 "path/filepath"
6 "regexp"
67 "strings"
78 "testing"
89)
@@ -88,7 +89,7 @@ func TestMergeGatesVisible(t *testing.T) {
8889 if strings.Contains(out, `"unmet"`) || !strings.Contains(out, `"approvals":["bob"]`) {
8990 t.Fatalf("gates after approval:\n%s", out)
9091 }
91 if out, _, _ := inst.ssh(t, aliceKey, "", "mr", "show", "alice/svc", "1"); !strings.Contains(out, "met; fast-forward possible") {
92 if out, _, _ := inst.ssh(t, aliceKey, "", "mr", "show", "alice/svc", "1"); !regexp.MustCompile(`gates\s+met; fast-forward possible`).MatchString(out) {
9293 t.Fatalf("text gates line: %s", out)
9394 }
9495 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1"); code != 0 {
e2e/reposhowstate_test.go +2 −1
@@ -1,6 +1,7 @@
11package e2e
22
33import (
4 "regexp"
45 "strings"
56 "testing"
67)
@@ -54,7 +55,7 @@ func TestRepoShowCarriesViewerState(t *testing.T) {
5455 }
5556
5657 // Plain output carries the same.
57 if out, _, _ := inst.ssh(t, bobKey, "", "repo", "show", "alice/app"); !strings.Contains(out, "watching") ||
58 if out, _, _ := inst.ssh(t, bobKey, "", "repo", "show", "alice/app"); !regexp.MustCompile(`watch\s+watching`).MatchString(out) ||
5859 !strings.Contains(out, "bookmarked") {
5960 t.Errorf("plain output lacks the state:\n%s", out)
6061 }
internal/control/admin.go +8 −5
@@ -263,7 +263,7 @@ func runAdminUserShow(c *Ctx, args []string) int {
263263 "web sessions", fmt.Sprintf("%d", d.WebSessions),
264264 )
265265 if len(d.Keys) > 0 {
266 io.WriteString(w, "\n")
266 v.section("keys")
267267 tk := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LAST USED")
268268 for _, k := range d.Keys {
269269 tk.row(cFlex(k.Fingerprint), cText(k.Algo), cState(k.Scope), cAge(k.LastUsedAt))
@@ -271,7 +271,7 @@ func runAdminUserShow(c *Ctx, args []string) int {
271271 tk.flush()
272272 }
273273 if len(d.Emails) > 0 {
274 io.WriteString(w, "\n")
274 v.section("emails")
275275 te := c.table(w, "ADDRESS", "STATE")
276276 for _, e := range d.Emails {
277277 state := "unverified"
@@ -287,7 +287,7 @@ func runAdminUserShow(c *Ctx, args []string) int {
287287 te.flush()
288288 }
289289 if len(d.PGPKeys) > 0 {
290 io.WriteString(w, "\n")
290 v.section("pgp keys")
291291 tp := c.table(w, "FINGERPRINT")
292292 for _, k := range d.PGPKeys {
293293 tp.row(cFlex(k.Fingerprint))
@@ -295,7 +295,7 @@ func runAdminUserShow(c *Ctx, args []string) int {
295295 tp.flush()
296296 }
297297 if len(d.Orgs) > 0 {
298 io.WriteString(w, "\n")
298 v.section("orgs")
299299 to := c.table(w, "ORG", "ROLE")
300300 for _, o := range d.Orgs {
301301 to.row(cRef(o.Org), cState(o.Role))
@@ -303,7 +303,7 @@ func runAdminUserShow(c *Ctx, args []string) int {
303303 to.flush()
304304 }
305305 if len(d.APITokens) > 0 {
306 io.WriteString(w, "\n")
306 v.section("api tokens")
307307 tt := c.table(w, "NAME", "SCOPE", "LAST USED")
308308 for _, t := range d.APITokens {
309309 used := ""
@@ -551,6 +551,9 @@ func runAdminRunners(c *Ctx, args []string) int {
551551 "wait max", fmt.Sprintf("%ds", queue.ClaimWaitMaxS),
552552 "reaped 24h", fmt.Sprintf("%d", queue.Reaped24h),
553553 )
554 if len(runners) > 0 {
555 v.section("runners")
556 }
554557 tb := c.table(w, "USER", "FINGERPRINT", "LAST SEEN", "SCOPE", "HELD")
555558 for _, r := range runners {
556559 scope := r.Scope
internal/control/mr.go +32 −25
@@ -641,8 +641,12 @@ func runMRShow(c *Ctx, args []string) int {
641641 if d.UnresolvedThreads > 0 {
642642 unresolved = fmt.Sprintf("%d", d.UnresolvedThreads)
643643 }
644 v.fields(
645 "author", d.Author+", "+c.when(d.CreatedAt),
644 // One fields call for every one-row fact, including the unmet
645 // gates and the commit/check/review that only has a single row
646 // to show: separate calls each compute their own key width, so
647 // keeping them in one call keeps every key aligned.
648 kv := []string{
649 "author", d.Author + ", " + c.when(d.CreatedAt),
646650 "source", fmt.Sprintf("%s -> %s", d.Source, d.TargetRef),
647651 "head", fmt.Sprintf("%.10s", d.HeadSHA),
648652 "milestone", d.Milestone,
@@ -655,20 +659,32 @@ func runMRShow(c *Ctx, args []string) int {
655659 "superseded by", superseded,
656660 "unresolved threads", unresolved,
657661 "gates", gates,
658 "url", c.siteURL(repo.Path(), "mrs", strconv.FormatInt(d.Number, 10)),
659 )
660 if g := d.Gates; g != nil && len(g.Unmet) > 0 {
661 var kv []string
662 }
663 if g := d.Gates; g != nil {
662664 for _, u := range g.Unmet {
663665 kv = append(kv, "unmet", u)
664666 }
665 v.fields(kv...)
666667 }
668 if len(commits) == 1 {
669 kv = append(kv, "commit", fmt.Sprintf("%.10s %s", commits[0].SHA, commits[0].Subject))
670 }
671 if len(checks) == 1 {
672 x := checks[0]
673 dur := ""
674 if x.Duration != "" {
675 dur = " in " + x.Duration
676 }
677 kv = append(kv, "check", fmt.Sprintf("%s %s at %s%s", x.Context, x.State, c.when(x.UpdatedAt), dur))
678 }
679 if len(rs) == 1 {
680 kv = append(kv, "review", reviewLine(rs[0])+" at "+c.when(rs[0].CreatedAt))
681 }
682 kv = append(kv, "url", c.siteURL(repo.Path(), "mrs", strconv.FormatInt(d.Number, 10)))
683 v.fields(kv...)
684
667685 v.body(d.Body, d.BodyFormat)
668686
669 if len(commits) == 1 {
670 v.fields("commit", fmt.Sprintf("%.10s %s", commits[0].SHA, commits[0].Subject))
671 } else if len(commits) > 1 {
687 if len(commits) > 1 {
672688 io.WriteString(w, "\n")
673689 tb := c.table(w, "SHA", "SUBJECT")
674690 for _, cm := range commits {
@@ -677,26 +693,17 @@ func runMRShow(c *Ctx, args []string) int {
677693 tb.flush()
678694 }
679695
680 if len(checks) == 1 {
681 x := checks[0]
682 dur := ""
683 if x.Duration != "" {
684 dur = " in " + x.Duration
685 }
686 v.fields("check", fmt.Sprintf("%s %s at %s%s", x.Context, x.State, c.when(x.UpdatedAt), dur))
687 } else if len(checks) > 1 {
688 io.WriteString(w, "\n")
689 tb := c.table(w, "CHECK", "STATE", "UPDATED")
696 if len(checks) > 1 {
697 v.section("check")
698 tb := c.table(w, "CHECK", "STATE", "DURATION", "UPDATED")
690699 for _, x := range checks {
691 tb.row(cText(x.Context), cState(x.State), cText(c.when(x.UpdatedAt)))
700 tb.row(cText(x.Context), cState(x.State), cText(x.Duration), cText(c.when(x.UpdatedAt)))
692701 }
693702 tb.flush()
694703 }
695704
696 if len(rs) == 1 {
697 v.fields("review", reviewLine(rs[0])+" at "+c.when(rs[0].CreatedAt))
698 } else if len(rs) > 1 {
699 io.WriteString(w, "\n")
705 if len(rs) > 1 {
706 v.section("review")
700707 tb := c.table(w, "REVIEWER", "VERDICT", "WHEN")
701708 for _, r := range rs {
702709 verdict := r.Verdict
internal/control/profile.go +4 −4
@@ -252,7 +252,7 @@ func emitProfile(c *Ctx, d ProfileOut) int {
252252 "activity", activity,
253253 )
254254 if len(d.Links) > 0 {
255 io.WriteString(w, "\n")
255 v.section("link")
256256 tb := c.table(w, "LINK", "URL")
257257 for _, l := range d.Links {
258258 tb.row(cText(l.Label), cFlex(l.URL))
@@ -260,7 +260,7 @@ func emitProfile(c *Ctx, d ProfileOut) int {
260260 tb.flush()
261261 }
262262 if len(d.Orgs) > 0 {
263 io.WriteString(w, "\n")
263 v.section("org")
264264 tb := c.table(w, "ORG", "ROLE")
265265 for _, m := range d.Orgs {
266266 tb.row(cRef(m.Name), cState(m.Role))
@@ -268,7 +268,7 @@ func emitProfile(c *Ctx, d ProfileOut) int {
268268 tb.flush()
269269 }
270270 if len(d.Members) > 0 {
271 io.WriteString(w, "\n")
271 v.section("member")
272272 tb := c.table(w, "MEMBER", "ROLE")
273273 for _, m := range d.Members {
274274 tb.row(cRef(m.Name), cState(m.Role))
@@ -276,7 +276,7 @@ func emitProfile(c *Ctx, d ProfileOut) int {
276276 tb.flush()
277277 }
278278 if len(d.Repos) > 0 {
279 io.WriteString(w, "\n")
279 v.section("repo")
280280 tb := c.table(w, "REPO", "VISIBILITY", "DESCRIPTION")
281281 for _, r := range d.Repos {
282282 tb.row(cRef(r.Path), cState(r.Visibility), cFlex(r.Description))
internal/control/release.go +1 −1
@@ -279,7 +279,7 @@ func runReleaseShow(c *Ctx, args []string) int {
279279 io.WriteString(w, "\n")
280280 tb := c.table(w, "NAME", "SIZE", "SHA256")
281281 for _, a := range d.Assets {
282 tb.row(cRef(a.Name), cNum(a.Size), cText(a.SHA256[:min(10, len(a.SHA256))]))
282 tb.row(cRef(a.Name), cNum(a.Size), cFlex(a.SHA256))
283283 }
284284 tb.flush()
285285 }
internal/control/repo.go +1 −1
@@ -384,7 +384,7 @@ func runRepoShow(c *Ctx, args []string) int {
384384 "url", c.siteURL(d.Path),
385385 )
386386 if len(d.Mirrors) > 0 {
387 io.WriteString(w, "\n")
387 v.section("mirror")
388388 tb := c.table(w, "DIRECTION", "URL", "LAST SYNC", "STATUS")
389389 for _, m := range d.Mirrors {
390390 status := "ok"
internal/control/view.go +32 −5
@@ -28,8 +28,9 @@ func (c *Ctx) siteURL(parts ...string) string {
2828// view lays out a show: a title line, aligned fields, a body, events,
2929// comments. Plain output is the same lines without colour or wrapping.
3030type view struct {
31 c *Ctx
32 w io.Writer
31 c *Ctx
32 w io.Writer
33 wrote bool // has this view written anything yet
3334}
3435
3536func (c *Ctx) view(w io.Writer) *view { return &view{c: c, w: w} }
@@ -38,12 +39,36 @@ func (v *view) opts() termtext.Options {
3839 return termtext.Options{Width: max(0, v.c.Term.Cols-2), Color: v.c.Term.Color, Base: v.c.Cfg.Server.SiteURL}
3940}
4041
42// sep writes a blank line before the next block, unless this view has
43// written nothing yet: fields, a body or a section as the first thing a
44// command prints (admin runners, admin stats, notifications settings
45// show, ...) does not open with an empty line.
46func (v *view) sep() {
47 if v.wrote {
48 io.WriteString(v.w, "\n")
49 }
50 v.wrote = true
51}
52
53// section prints a sub-table's label: a blank line, then the label bold
54// at a terminal or "label:" in plain. Callers skip the call entirely
55// when the table it introduces has no rows.
56func (v *view) section(label string) {
57 v.sep()
58 if v.c.Term.Cols == 0 {
59 io.WriteString(v.w, label+":\n")
60 return
61 }
62 io.WriteString(v.w, v.c.Term.paint(sgrBold, label)+"\n")
63}
64
4165// title prints "ref title state", wrapping title+state to the
4266// terminal width. Continuation lines indent under the title, and each
4367// line is painted after wrapping so no SGR sequence crosses a break.
4468// title or state may be "": either is skipped rather than leaving a
4569// trailing blank field.
4670func (v *view) title(ref, title, state string) {
71 v.sep()
4772 t := v.c.Term
4873 if t.Cols == 0 {
4974 switch {
@@ -107,7 +132,7 @@ func (v *view) fields(kv ...string) {
107132 wide = max(wide, cells(kv[i]))
108133 }
109134 }
110 io.WriteString(v.w, "\n")
135 v.sep()
111136 for i := 0; i+1 < len(kv); i += 2 {
112137 key, val := kv[i], kv[i+1]
113138 if val == "" {
@@ -133,7 +158,7 @@ func (v *view) body(src, format string) {
133158 if strings.TrimSpace(src) == "" {
134159 return
135160 }
136 io.WriteString(v.w, "\n")
161 v.sep()
137162 for _, line := range strings.Split(strings.TrimRight(termtext.Render(src, format, v.opts()), "\n"), "\n") {
138163 if line == "" {
139164 io.WriteString(v.w, "\n")
@@ -153,6 +178,7 @@ func (v *view) event(text, format, ts string) {
153178 line = pad(clip(line, room), room)
154179 }
155180 io.WriteString(v.w, " "+v.c.Term.paint(sgrDim, line+" "+when)+"\n")
181 v.wrote = true
156182}
157183
158184func (v *view) comment(author, ts, body, format string) {
@@ -166,6 +192,7 @@ func (v *view) comment(author, ts, body, format string) {
166192 if cols > 0 {
167193 head += strings.Repeat("─", max(1, cols-cells(head)))
168194 }
169 io.WriteString(v.w, "\n"+v.c.Term.paint(sgrDim, head)+"\n")
195 v.sep()
196 io.WriteString(v.w, v.c.Term.paint(sgrDim, head)+"\n")
170197 v.body(body, format)
171198}
internal/control/wiki.go +6
@@ -168,6 +168,12 @@ func runWikiShow(c *Ctx, args []string) int {
168168 format = "org"
169169 }
170170 return c.emit(d, func(w io.Writer) {
171 // Plain: the page source verbatim, same as any other piped
172 // file read. The title/fields/body layout is terminal-only.
173 if c.Term.Cols == 0 {
174 fmt.Fprint(w, d.Content)
175 return
176 }
171177 v := c.view(w)
172178 v.title(repo.Path(), page, "")
173179 binaryNote := ""