Commit 061d70831a

061d70831adefcfb75d171c119662bb4528dd73e

parent: e410f8a408

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-24 00:56 UTC

control: table, plain rows or a fitted terminal table

Ref #254
internal/control/control.go +3
@@ -27,6 +27,9 @@ type Ctx struct {
2727 Stdout io.Writer
2828 Stderr io.Writer
2929 JSON bool
30 // Term is the client's terminal, from GITBAY_TERM. The zero value
31 // is plain output.
32 Term Term
3033 // ViaAPI marks requests arriving over HTTP, from the token API or
3134 // the web. Every command runs there; nothing is held back for SSH
3235 // any more (#234). The flag stays because the rate limiter and the
internal/control/table.go added +191
@@ -0,0 +1,191 @@
1package control
2
3import (
4 "io"
5 "strconv"
6 "strings"
7)
8
9type cellKind int
10
11const (
12 kindText cellKind = iota
13 kindFlex
14 kindRef
15 kindState
16 kindAge
17 kindNum
18)
19
20// cell is one column of a table row. The kind decides colour, time
21// format, and whether the column may be clipped to fit the terminal.
22type cell struct {
23 kind cellKind
24 s string
25}
26
27func cRef(s string) cell { return cell{kindRef, s} }
28func cState(s string) cell { return cell{kindState, s} }
29func cText(s string) cell { return cell{kindText, s} }
30func cFlex(s string) cell { return cell{kindFlex, s} }
31func cAge(ts string) cell { return cell{kindAge, ts} }
32func cNum(n int64) cell { return cell{kindNum, strconv.FormatInt(n, 10)} }
33
34// table is a list command's rows. Plain, each row is written as it
35// comes, tab-separated with no header. At a terminal rows are held
36// until flush, then written under a header, padded, and fitted to the
37// width.
38type table struct {
39 term Term
40 w io.Writer
41 header []string
42 rows [][]cell
43}
44
45func (c *Ctx) table(w io.Writer, header ...string) *table {
46 return &table{term: c.Term, w: w, header: header}
47}
48
49func (t *table) row(cs ...cell) {
50 if t.term.Cols == 0 {
51 parts := make([]string, len(cs))
52 for i, c := range cs {
53 if c.kind == kindAge {
54 parts[i] = stamp(c.s)
55 } else {
56 parts[i] = c.s
57 }
58 }
59 io.WriteString(t.w, strings.Join(parts, "\t")+"\n")
60 return
61 }
62 now := termNow()
63 for i := range cs {
64 if cs[i].kind == kindAge {
65 cs[i].s = relAge(cs[i].s, now)
66 }
67 }
68 t.rows = append(t.rows, cs)
69}
70
71func (t *table) flush() {
72 if t.term.Cols == 0 || len(t.rows) == 0 {
73 return
74 }
75 n := len(t.header)
76 widths := make([]int, n)
77 for i, h := range t.header {
78 widths[i] = cells(h)
79 }
80 for _, r := range t.rows {
81 for i := 0; i < n && i < len(r); i++ {
82 widths[i] = max(widths[i], cells(r[i].s))
83 }
84 }
85 t.fit(widths)
86
87 var b strings.Builder
88 line := make([]string, n)
89 for i, h := range t.header {
90 line[i] = h
91 }
92 b.WriteString(t.term.paint(sgrDim, t.join(line, widths)) + "\n")
93 for _, r := range t.rows {
94 for i := 0; i < n; i++ {
95 s := ""
96 if i < len(r) {
97 s = clip(r[i].s, widths[i])
98 }
99 line[i] = s
100 }
101 b.WriteString(t.joinRow(r, line, widths) + "\n")
102 }
103 io.WriteString(t.w, b.String())
104}
105
106// fit shrinks columns until a row fits the terminal: the flexible
107// column first, down to 8 cells, then the other text columns from the
108// right, down to 8 each.
109func (t *table) fit(widths []int) {
110 total := func() int {
111 s := 2 * (len(widths) - 1)
112 for _, w := range widths {
113 s += w
114 }
115 return s
116 }
117 kinds := make([]cellKind, len(widths))
118 if len(t.rows) > 0 {
119 for i := range widths {
120 if i < len(t.rows[0]) {
121 kinds[i] = t.rows[0][i].kind
122 }
123 }
124 }
125 shrink := func(i int) {
126 if over := total() - t.term.Cols; over > 0 && widths[i] > 8 {
127 widths[i] = max(8, widths[i]-over)
128 }
129 }
130 for i, k := range kinds {
131 if k == kindFlex {
132 shrink(i)
133 }
134 }
135 for i := len(kinds) - 1; i >= 0; i-- {
136 if kinds[i] == kindText {
137 shrink(i)
138 }
139 }
140}
141
142// join pads every column but the last and separates them by two spaces.
143func (t *table) join(line []string, widths []int) string {
144 var b strings.Builder
145 for i, s := range line {
146 if i > 0 {
147 b.WriteString(" ")
148 }
149 if i == len(line)-1 {
150 b.WriteString(s)
151 } else {
152 b.WriteString(pad(s, widths[i]))
153 }
154 }
155 return b.String()
156}
157
158// joinRow is join with state cells coloured after padding, so the
159// SGR bytes never count against the width.
160func (t *table) joinRow(r []cell, line []string, widths []int) string {
161 var b strings.Builder
162 for i, s := range line {
163 if i > 0 {
164 b.WriteString(" ")
165 }
166 padding := ""
167 if i < len(line)-1 {
168 padding = strings.Repeat(" ", max(0, widths[i]-cells(s)))
169 }
170 if i < len(r) && r[i].kind == kindState {
171 s = t.term.paint(stateColor(s), s)
172 }
173 b.WriteString(s + padding)
174 }
175 return b.String()
176}
177
178// stripSGR removes SGR sequences, for tests and width checks.
179func stripSGR(s string) string {
180 var b strings.Builder
181 for i := 0; i < len(s); i++ {
182 if s[i] == 0x1b {
183 if j := strings.IndexByte(s[i:], 'm'); j >= 0 {
184 i += j
185 continue
186 }
187 }
188 b.WriteByte(s[i])
189 }
190 return b.String()
191}
internal/control/table_test.go added +79
@@ -0,0 +1,79 @@
1package control
2
3import (
4 "bytes"
5 "strings"
6 "testing"
7 "time"
8)
9
10func fixtureTable(c *Ctx, w *bytes.Buffer) {
11 tb := c.table(w, "#", "STATE", "TITLE", "AUTHOR")
12 tb.row(cRef("#252"), cState("open"), cFlex("Dependency updates available for every module"), cText("gitbay-bot"))
13 tb.row(cRef("#12"), cState("closed"), cFlex("Android app"), cText("cmc"))
14 tb.flush()
15}
16
17func TestTablePlainIsTabs(t *testing.T) {
18 var b bytes.Buffer
19 fixtureTable(&Ctx{}, &b)
20 want := "#252\topen\tDependency updates available for every module\tgitbay-bot\n" +
21 "#12\tclosed\tAndroid app\tcmc\n"
22 if b.String() != want {
23 t.Errorf("plain:\n%q\nwant\n%q", b.String(), want)
24 }
25}
26
27func TestTableTerminalFits(t *testing.T) {
28 var b bytes.Buffer
29 fixtureTable(&Ctx{Term: Term{Cols: 40}}, &b)
30 want := "# STATE TITLE AUTHOR\n" +
31 "#252 open Dependency up… gitbay-bot\n" +
32 "#12 closed Android app cmc\n"
33 if b.String() != want {
34 t.Errorf("terminal:\n%s\nwant\n%s", b.String(), want)
35 }
36}
37
38func TestTableColourOnlyAddsSGR(t *testing.T) {
39 var mono, colour bytes.Buffer
40 fixtureTable(&Ctx{Term: Term{Cols: 40}}, &mono)
41 fixtureTable(&Ctx{Term: Term{Cols: 40, Color: true}}, &colour)
42 if !strings.Contains(colour.String(), sgrGreen+"open"+sgrReset) {
43 t.Errorf("open not green: %q", colour.String())
44 }
45 if !strings.HasPrefix(colour.String(), sgrDim) {
46 t.Errorf("header not dim: %q", colour.String())
47 }
48 if stripSGR(colour.String()) != mono.String() {
49 t.Errorf("colour changed the layout:\n%s\nvs\n%s", stripSGR(colour.String()), mono.String())
50 }
51}
52
53func TestTableAgesAndPlainStamps(t *testing.T) {
54 termNow = func() time.Time { return time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC) }
55 t.Cleanup(func() { termNow = time.Now })
56 var plain, term bytes.Buffer
57 for _, c := range []struct {
58 ctx *Ctx
59 w *bytes.Buffer
60 }{{&Ctx{}, &plain}, {&Ctx{Term: Term{Cols: 80}}, &term}} {
61 tb := c.ctx.table(c.w, "#", "UPDATED")
62 tb.row(cRef("#1"), cAge("2026-09-23T10:00:00.123Z"))
63 tb.flush()
64 }
65 if plain.String() != "#1\t2026-09-23T10:00:00Z\n" {
66 t.Errorf("plain = %q", plain.String())
67 }
68 if term.String() != "# UPDATED\n#1 2h ago\n" {
69 t.Errorf("term = %q", term.String())
70 }
71}
72
73func TestTableEmptyPrintsNothing(t *testing.T) {
74 var b bytes.Buffer
75 (&Ctx{Term: Term{Cols: 80}}).table(&b, "#").flush()
76 if b.Len() != 0 {
77 t.Errorf("empty table printed %q", b.String())
78 }
79}