Commit 6cc1494e6b

6cc1494e6b6e254f073e5fe1ca0af6e58b0f731d

parent: 0c0ae8e08b

Verified · cmc

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

control: tables name their columns only when they hold numbers

Ref #317

Layout: unified · split

internal/control/search_test.go +7 −11
@@ -6,9 +6,9 @@ import (
6 "testing" 6 "testing"
7) 7)
8 8
9// A repo hit has no state, an issue/mr hit does. At a terminal both must 9// A repo hit has no state, an issue/mr hit does. At a terminal both
10// still put TITLE under the TITLE header, not have the repo's title slide 10// titles must start in the same column, not have the repo's title slide
11// left under STATE. 11// left into the state column.
12func TestSearchTableAlignsTitleAtTerminal(t *testing.T) { 12func TestSearchTableAlignsTitleAtTerminal(t *testing.T) {
13 results := []SearchResult{ 13 results := []SearchResult{
14 {Kind: "repo", Repo: "alice/webapp", Title: "a web application"}, 14 {Kind: "repo", Repo: "alice/webapp", Title: "a web application"},
@@ -18,22 +18,18 @@ func TestSearchTableAlignsTitleAtTerminal(t *testing.T) {
18 writeSearchTable(&Ctx{Term: Term{Cols: 100}}, &b, results) 18 writeSearchTable(&Ctx{Term: Term{Cols: 100}}, &b, results)
19 19
20 lines := strings.Split(strings.TrimRight(b.String(), "\n"), "\n") 20 lines := strings.Split(strings.TrimRight(b.String(), "\n"), "\n")
21 if len(lines) != 3 { 21 if len(lines) != 2 {
22 t.Fatalf("want header + 2 rows, got %d lines:\n%s", len(lines), b.String()) 22 t.Fatalf("want 2 rows, got %d lines:\n%s", len(lines), b.String())
23 } 23 }
24 header, repoRow, issueRow := lines[0], lines[1], lines[2] 24 repoRow, issueRow := lines[0], lines[1]
25 titleAt := strings.Index(header, "TITLE")
26 repoTitleAt := strings.Index(repoRow, "a web application") 25 repoTitleAt := strings.Index(repoRow, "a web application")
27 issueTitleAt := strings.Index(issueRow, "memory leak") 26 issueTitleAt := strings.Index(issueRow, "memory leak")
28 if titleAt < 0 || repoTitleAt < 0 || issueTitleAt < 0 { 27 if repoTitleAt < 0 || issueTitleAt < 0 {
29 t.Fatalf("columns not found:\n%s", b.String()) 28 t.Fatalf("columns not found:\n%s", b.String())
30 } 29 }
31 if repoTitleAt != issueTitleAt { 30 if repoTitleAt != issueTitleAt {
32 t.Errorf("titles not aligned: repo row at %d, issue row at %d\n%s", repoTitleAt, issueTitleAt, b.String()) 31 t.Errorf("titles not aligned: repo row at %d, issue row at %d\n%s", repoTitleAt, issueTitleAt, b.String())
33 } 32 }
34 if repoTitleAt != titleAt {
35 t.Errorf("title not under TITLE header: header at %d, repo row at %d\n%s", titleAt, repoTitleAt, b.String())
36 }
37} 33}
38 34
39// Plain output has no header to align to, so a repo row stays 3 cells — 35// Plain output has no header to align to, so a repo row stays 3 cells —
internal/control/table.go +38 −9
@@ -74,8 +74,8 @@ func cMeta(parts ...string) cell {
74 74
75// table is a list command's rows. Plain, each row is written as it 75// table is a list command's rows. Plain, each row is written as it
76// comes, tab-separated with no header. At a terminal rows are held 76// comes, tab-separated with no header. At a terminal rows are held
77// until flush, then written under a header, padded, and fitted to the 77// until flush, then padded and fitted to the width, under a header only
78// width. 78// when a column holds numbers.
79type table struct { 79type table struct {
80 term Term 80 term Term
81 w io.Writer 81 w io.Writer
@@ -135,6 +135,32 @@ func (t *table) flush() {
135 if t.term.Cols == 0 || len(t.rows) == 0 { 135 if t.term.Cols == 0 || len(t.rows) == 0 {
136 return 136 return
137 } 137 }
138 var b strings.Builder
139 for _, l := range t.lines() {
140 b.WriteString(l + "\n")
141 }
142 io.WriteString(t.w, b.String())
143}
144
145// numeric reports whether a column holds numbers or sizes, which need a
146// header to say what they count.
147func (t *table) numeric() bool {
148 for _, r := range t.rows {
149 for _, c := range r {
150 if c.kind == kindNum || c.kind == kindSize {
151 return true
152 }
153 }
154 }
155 return false
156}
157
158// lines lays the rows out at the terminal width: a dim header first
159// only when a column is a number, then each row padded and painted.
160func (t *table) lines() []string {
161 if !t.numeric() {
162 t.header = nil
163 }
138 t.dropEmpty() 164 t.dropEmpty()
139 // The column count is never smaller than the longest row: a row with 165 // The column count is never smaller than the longest row: a row with
140 // more cells than the header has still gets every cell rendered, the 166 // more cells than the header has still gets every cell rendered, the
@@ -155,14 +181,17 @@ func (t *table) flush() {
155 t.capSparse(widths) 181 t.capSparse(widths)
156 t.fit(widths) 182 t.fit(widths)
157 183
158 var b strings.Builder 184 var out []string
159 line := make([]string, n) 185 line := make([]string, n)
160 for i := range line { 186 if len(t.header) > 0 {
161 if i < len(t.header) { 187 for i := range line {
162 line[i] = clip(t.header[i], widths[i]) 188 line[i] = ""
189 if i < len(t.header) {
190 line[i] = clip(t.header[i], widths[i])
191 }
163 } 192 }
193 out = append(out, t.term.paint(sgrDim, strings.TrimRight(t.join(line, widths), " ")))
164 } 194 }
165 b.WriteString(t.term.paint(sgrDim, strings.TrimRight(t.join(line, widths), " ")) + "\n")
166 for _, r := range t.rows { 195 for _, r := range t.rows {
167 for i := 0; i < n; i++ { 196 for i := 0; i < n; i++ {
168 s := "" 197 s := ""
@@ -171,9 +200,9 @@ func (t *table) flush() {
171 } 200 }
172 line[i] = s 201 line[i] = s
173 } 202 }
174 b.WriteString(strings.TrimRight(t.joinRow(r, line, widths), " ") + "\n") 203 out = append(out, strings.TrimRight(t.joinRow(r, line, widths), " "))
175 } 204 }
176 io.WriteString(t.w, b.String()) 205 return out
177} 206}
178 207
179// dropEmpty removes a column that is blank on every row, header and 208// dropEmpty removes a column that is blank on every row, header and
internal/control/table_test.go +54 −24
@@ -27,26 +27,26 @@ func TestTablePlainIsTabs(t *testing.T) {
27func TestTableTerminalFits(t *testing.T) { 27func TestTableTerminalFits(t *testing.T) {
28 var b bytes.Buffer 28 var b bytes.Buffer
29 fixtureTable(&Ctx{Term: Term{Cols: 40}}, &b) 29 fixtureTable(&Ctx{Term: Term{Cols: 40}}, &b)
30 want := "# STATE TITLE AUTHOR\n" + 30 want := "#252 open Dependency up… gitbay-bot\n" +
31 "#252 open Dependency up… gitbay-bot\n" +
32 "#12 closed Android app cmc\n" 31 "#12 closed Android app cmc\n"
33 if b.String() != want { 32 if b.String() != want {
34 t.Errorf("terminal:\n%s\nwant\n%s", b.String(), want) 33 t.Errorf("terminal:\n%s\nwant\n%s", b.String(), want)
35 } 34 }
36} 35}
37 36
38// A column shrunk below its header's width clips the header too. 37// A column shrunk below its header's width clips the header too. The
38// number column is what keeps the header.
39func TestTableClipsHeaderToColumn(t *testing.T) { 39func TestTableClipsHeaderToColumn(t *testing.T) {
40 var b bytes.Buffer 40 var b bytes.Buffer
41 tb := (&Ctx{Term: Term{Cols: 16}}).table(&b, "FINGERPRINT", "SCOPE") 41 tb := (&Ctx{Term: Term{Cols: 12}}).table(&b, "FINGERPRINTS", "N")
42 tb.row(cFlex("SHA256:abcdefghijklmnopqrstuvwxyz"), cState("full")) 42 tb.row(cFlex("SHA256:abcdefghijklmnopqrstuvwxyz"), cNum(1))
43 tb.flush() 43 tb.flush()
44 for _, line := range strings.Split(strings.TrimSuffix(b.String(), "\n"), "\n") { 44 for _, line := range strings.Split(strings.TrimSuffix(b.String(), "\n"), "\n") {
45 if cells(line) > 16 { 45 if cells(line) > 12 {
46 t.Errorf("line of %d cells at 16 columns: %q", cells(line), line) 46 t.Errorf("line of %d cells at 12 columns: %q", cells(line), line)
47 } 47 }
48 } 48 }
49 if !strings.HasPrefix(b.String(), "FINGERPR… SCOPE\n") { 49 if !strings.HasPrefix(b.String(), "FINGERPR… N\n") {
50 t.Errorf("header:\n%s", b.String()) 50 t.Errorf("header:\n%s", b.String())
51 } 51 }
52} 52}
@@ -58,8 +58,8 @@ func TestTableColourOnlyAddsSGR(t *testing.T) {
58 if !strings.Contains(colour.String(), sgrGreen+"open"+sgrReset) { 58 if !strings.Contains(colour.String(), sgrGreen+"open"+sgrReset) {
59 t.Errorf("open not green: %q", colour.String()) 59 t.Errorf("open not green: %q", colour.String())
60 } 60 }
61 if !strings.HasPrefix(colour.String(), sgrDim) { 61 if !strings.HasPrefix(colour.String(), sgrDim+"#252"+sgrReset) {
62 t.Errorf("header not dim: %q", colour.String()) 62 t.Errorf("ref not dim: %q", colour.String())
63 } 63 }
64 if stripSGR(colour.String()) != mono.String() { 64 if stripSGR(colour.String()) != mono.String() {
65 t.Errorf("colour changed the layout:\n%s\nvs\n%s", stripSGR(colour.String()), mono.String()) 65 t.Errorf("colour changed the layout:\n%s\nvs\n%s", stripSGR(colour.String()), mono.String())
@@ -81,7 +81,7 @@ func TestTableAgesAndPlainStamps(t *testing.T) {
81 if plain.String() != "#1\t2026-09-23T10:00:00Z\n" { 81 if plain.String() != "#1\t2026-09-23T10:00:00Z\n" {
82 t.Errorf("plain = %q", plain.String()) 82 t.Errorf("plain = %q", plain.String())
83 } 83 }
84 if term.String() != "# UPDATED\n#1 2h ago\n" { 84 if term.String() != "#1 2h ago\n" {
85 t.Errorf("term = %q", term.String()) 85 t.Errorf("term = %q", term.String())
86 } 86 }
87} 87}
@@ -102,18 +102,16 @@ func TestTableKeepsCellsBeyondTheHeader(t *testing.T) {
102 t.Fatalf("archived marker dropped:\n%s", out) 102 t.Fatalf("archived marker dropped:\n%s", out)
103 } 103 }
104 lines := strings.Split(strings.TrimRight(out, "\n"), "\n") 104 lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
105 if len(lines) != 3 { 105 if len(lines) != 2 {
106 t.Fatalf("want 3 lines, got %d:\n%s", len(lines), out) 106 t.Fatalf("want 2 lines, got %d:\n%s", len(lines), out)
107 } 107 }
108 header, row1, row2 := lines[0], lines[1], lines[2] 108 oneAt := strings.Index(lines[0], "one")
109 descAt := strings.Index(header, "DESCRIPTION") 109 twoAt := strings.Index(lines[1], "two")
110 oneAt := strings.Index(row1, "one") 110 if oneAt < 0 || twoAt < 0 {
111 twoAt := strings.Index(row2, "two")
112 if descAt < 0 || oneAt < 0 || twoAt < 0 {
113 t.Fatalf("columns not found:\n%s", out) 111 t.Fatalf("columns not found:\n%s", out)
114 } 112 }
115 if descAt != oneAt || descAt != twoAt { 113 if oneAt != twoAt {
116 t.Errorf("DESCRIPTION column not aligned: header at %d, row1 at %d, row2 at %d\n%s", descAt, oneAt, twoAt, out) 114 t.Errorf("description column not aligned: row1 at %d, row2 at %d\n%s", oneAt, twoAt, out)
117 } 115 }
118} 116}
119 117
@@ -149,8 +147,8 @@ func TestTableCapsSparseFlex(t *testing.T) {
149 tb.row(cRef("v0"), cFlex(strings.Repeat("x", 60)), cText("2")) 147 tb.row(cRef("v0"), cFlex(strings.Repeat("x", 60)), cText("2"))
150 tb.flush() 148 tb.flush()
151 lines := strings.Split(b.String(), "\n") 149 lines := strings.Split(b.String(), "\n")
152 if at := strings.Index(lines[0], "ASSETS"); at != len("TAG ")+30+2 { 150 if at := strings.LastIndex(lines[0], "2"); at != len("v3 ")+30+2 {
153 t.Errorf("ASSETS at %d:\n%s", at, b.String()) 151 t.Errorf("assets at %d:\n%s", at, b.String())
154 } 152 }
155} 153}
156 154
@@ -178,7 +176,7 @@ func TestTableDropsEmptyColumns(t *testing.T) {
178 tb.row(cRef("#1"), cText(""), cFlex("one")) 176 tb.row(cRef("#1"), cText(""), cFlex("one"))
179 tb.row(cRef("#2"), cText(""), cFlex("two")) 177 tb.row(cRef("#2"), cText(""), cFlex("two"))
180 tb.flush() 178 tb.flush()
181 if b.String() != "# TITLE\n#1 one\n#2 two\n" { 179 if b.String() != "#1 one\n#2 two\n" {
182 t.Errorf("terminal = %q", b.String()) 180 t.Errorf("terminal = %q", b.String())
183 } 181 }
184 var plain bytes.Buffer 182 var plain bytes.Buffer
@@ -238,3 +236,35 @@ func TestGlyphAndMetaCells(t *testing.T) {
238 } 236 }
239 } 237 }
240} 238}
239
240func TestTableDropsHeaderWithoutNumbers(t *testing.T) {
241 var b bytes.Buffer
242 tb := (&Ctx{Term: Term{Cols: 80}}).table(&b, "#", "STATE", "TITLE")
243 tb.row(cRef("#12"), cState("open"), cFlex("Android app"))
244 tb.flush()
245 if got, want := b.String(), "#12 open Android app\n"; got != want {
246 t.Errorf("got %q, want %q", got, want)
247 }
248}
249
250func TestTableKeepsHeaderForNumbers(t *testing.T) {
251 var b bytes.Buffer
252 tb := (&Ctx{Term: Term{Cols: 80}}).table(&b, "RUNNER", "PENDING")
253 tb.row(cText("bay1"), cNum(3))
254 tb.flush()
255 if !strings.HasPrefix(b.String(), "RUNNER PENDING\n") {
256 t.Errorf("header missing: %q", b.String())
257 }
258}
259
260func TestTableLines(t *testing.T) {
261 var b bytes.Buffer
262 tb := (&Ctx{Term: Term{Cols: 80}}).table(&b)
263 tb.row(cRef("!1"), cFlex("one"))
264 tb.row(cRef("!22"), cFlex("two"))
265 got := tb.lines()
266 want := []string{"!1 one", "!22 two"}
267 if strings.Join(got, "\n") != strings.Join(want, "\n") {
268 t.Errorf("got %q, want %q", got, want)
269 }
270}