Commit 4837660d33

4837660d3339aea30f511473d27e2ea4028465d3

parent: 791af1d907

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-10-01 20:53 UTC

control: terminal rendering foundations

At a terminal: colour by meaning (private red, signature states,
references cyan, yellow only for what waits on the viewer), future
times as "in 5h", sizes and durations humanized, markers joined to the
state cell instead of an unnamed column, no trailing whitespace, a
mostly blank flex column capped at a third of the width, one heading
style with empty dashboard sections left out, and errors after a red
"error:" with the usage wrapped between bracketed groups.

Everywhere: an unknown flag names the nearest flag, profile show's
section labels are plural, and ParseTerm ignores options it does not
know.

Closes #312

Layout: unified · split

.gitbay/wiki/Users.org +20 −5
@@ -1002,22 +1002,37 @@ terminal, and follows these rules so every noun reads the same way.
1002 1002
1003** At a terminal 1003** At a terminal
1004 1004
1005The =gitbay= CLI sends a leading =--term=<cols>[,color]= argument on 1005The =gitbay= CLI sends a leading =--term=<cols>[,<option>]...= argument on
1006the SSH command line when stdout is a terminal (OpenSSH's multiplexed 1006the SSH command line when stdout is a terminal (OpenSSH's multiplexed
1007sessions, which the CLI uses, do not forward a session's =SetEnv=). 1007sessions, which the CLI uses, do not forward a session's =SetEnv=).
1008The argument goes first: the server reads =--term=<v>= only as the 1008The argument goes first: the server reads =--term=<v>= only as the
1009first argument, and ignores it over HTTP. The server then prints: 1009first argument, and ignores it over HTTP. =color= is the one option
1010the server acts on; it ignores options it does not know. The server
1011then prints:
1010 1012
1011- lists under a header, padded, fitted to the width (the title or 1013- lists under a header, padded, fitted to the width (the title or
1012 description column is cut with =…= first), states in colour, ages as 1014 description column is cut with =…= first; a title or description
1013 =2h ago=, and the next page as a command on stderr (piped output 1015 column blank on most rows is held to a third of the width), with no
1014 keeps a =next\t<cursor>= row instead); 1016 trailing whitespace, and the next page as a command on stderr (piped
1017 output keeps a =next\t<cursor>= row instead);
1018- colour by meaning: green for open, success, approved, verified; magenta
1019 for merged; red for failures, =private=, and bad or untrusted
1020 signatures; dim for closed, pending, archived, unsigned; yellow only
1021 for what waits on you (an unverified address); references (=#12=,
1022 =krz/gitbay=, SHAs) in cyan. A marker piped output keeps as its own
1023 trailing cell (=[archived]=, =primary=) joins the state at a terminal:
1024 =public, archived=;
1025- ages as =2h ago= and =in 5h=, sizes as =3.0 MiB=, durations as
1026 =53m43s=, where piped output keeps RFC3339, bytes and seconds;
1015- =show= views with a title line, aligned fields, the body rendered 1027- =show= views with a title line, aligned fields, the body rendered
1016 from markdown or org, one line per event, and comments under a rule; 1028 from markdown or org, one line per event, and comments under a rule;
1017 a sub-table (labels, revisions, and the like) carries a section label 1029 a sub-table (labels, revisions, and the like) carries a section label
1018 line in both terminal and piped output; timestamps as 1030 line in both terminal and piped output; timestamps as
1019 =2026-09-23 23:26 UTC=. =wiki show= piped prints the page source 1031 =2026-09-23 23:26 UTC=. =wiki show= piped prints the page source
1020 verbatim; the rendered page is terminal-only; 1032 verbatim; the rendered page is terminal-only;
1033- errors on stderr after a red =error:=, a mistyped flag with the flag
1034 it is closest to (=did you mean --state?=), and a usage line wrapped
1035 to the width between its bracketed groups, one alternative per line;
1021- help with flag descriptions, defaults, and examples; =gitbay --help= 1036- help with flag descriptions, defaults, and examples; =gitbay --help=
1022 groups commands under WORK, REPOSITORIES, YOU and INSTANCE; 1037 groups commands under WORK, REPOSITORIES, YOU and INSTANCE;
1023 =help --json= adds =flags= and =examples=. 1038 =help --json= adds =flags= and =examples=.
CHANGELOG.org +11
@@ -16,6 +16,17 @@ anything beyond "replace the binary and restart" is needed.
16 draft controls to its author as well as to writers, matching the 16 draft controls to its author as well as to writers, matching the
17 commands: an author without write access could do it over SSH but had 17 commands: an author without write access could do it over SSH but had
18 no button. (#311) 18 no button. (#311)
19- Terminal output reads at a glance (#312): colour by meaning across
20 every list and view (=private= red, signature states, references in
21 cyan, yellow only for what waits on you), future times as =in 5h=,
22 sizes and durations humanized, no unnamed trailing column or trailing
23 whitespace, a mostly blank title column held to a third of the width,
24 section headings in one style with empty dashboard sections left out,
25 and errors after a red =error:= with =did you mean= for a mistyped
26 flag and the usage wrapped. Piped and =--json= output are unchanged,
27 except that =profile show='s section labels are plural (=links:=,
28 =orgs:=, =members:=, =repos:=) and an unknown flag's message names the
29 nearest flag. The server ignores =--term= options it does not know.
19 30
20* v1.40.1 — 2026-09-29 31* v1.40.1 — 2026-09-29
21 32
internal/control/admin.go +5 −5
@@ -312,7 +312,7 @@ func runAdminUserShow(c *Ctx, args []string) int {
312 } 312 }
313 cells := []cell{cRef(e.Address), cState(state)} 313 cells := []cell{cRef(e.Address), cState(state)}
314 if e.Primary { 314 if e.Primary {
315 cells = append(cells, cText("primary")) 315 cells = c.note(cells, 1, "primary", "primary")
316 } 316 }
317 te.row(cells...) 317 te.row(cells...)
318 } 318 }
@@ -442,9 +442,9 @@ func runAdminRepoList(c *Ctx, args []string) int {
442 return c.emitPage(p, ds, next, func(w io.Writer) { 442 return c.emitPage(p, ds, next, func(w io.Writer) {
443 tb := c.table(w, "PATH", "VISIBILITY", "BYTES", "CREATED", "LAST PUSH") 443 tb := c.table(w, "PATH", "VISIBILITY", "BYTES", "CREATED", "LAST PUSH")
444 for _, d := range ds { 444 for _, d := range ds {
445 cells := []cell{cRef(d.Path), cState(d.Visibility), cNum(d.Bytes), cAge(d.CreatedAt), cAge(d.LastPush)} 445 cells := []cell{cRef(d.Path), cState(d.Visibility), cSize(d.Bytes), cAge(d.CreatedAt), cAge(d.LastPush)}
446 if d.Archived { 446 if d.Archived {
447 cells = append(cells, cText("[archived]")) 447 cells = c.note(cells, 1, "[archived]", "archived")
448 } 448 }
449 tb.row(cells...) 449 tb.row(cells...)
450 } 450 }
@@ -579,8 +579,8 @@ func runAdminRunners(c *Ctx, args []string) int {
579 v.fields( 579 v.fields(
580 "pending", fmt.Sprintf("%d", queue.Pending), 580 "pending", fmt.Sprintf("%d", queue.Pending),
581 "claimed 24h", fmt.Sprintf("%d", queue.Claimed24h), 581 "claimed 24h", fmt.Sprintf("%d", queue.Claimed24h),
582 "wait avg", fmt.Sprintf("%ds", queue.ClaimWaitAvgS), 582 "wait avg", c.Term.dur(queue.ClaimWaitAvgS),
583 "wait max", fmt.Sprintf("%ds", queue.ClaimWaitMaxS), 583 "wait max", c.Term.dur(queue.ClaimWaitMaxS),
584 "reaped 24h", fmt.Sprintf("%d", queue.Reaped24h), 584 "reaped 24h", fmt.Sprintf("%d", queue.Reaped24h),
585 ) 585 )
586 if len(runners) > 0 { 586 if len(runners) > 0 {
internal/control/control.go +3
@@ -419,6 +419,9 @@ func (c *Ctx) fail(code int, format string, args ...any) int {
419 enc.SetEscapeHTML(false) 419 enc.SetEscapeHTML(false)
420 enc.Encode(protocol.Envelope{ProtocolVersion: protocol.Version, Error: msg}) 420 enc.Encode(protocol.Envelope{ProtocolVersion: protocol.Version, Error: msg})
421 } else { 421 } else {
422 if c.Term.Cols > 0 {
423 msg = c.Term.failure(msg)
424 }
422 fmt.Fprintln(c.Stderr, msg) 425 fmt.Fprintln(c.Stderr, msg)
423 } 426 }
424 return code 427 return code
internal/control/dashboard.go +21 −7
@@ -177,12 +177,25 @@ func runDashboard(c *Ctx, args []string) int {
177 } 177 }
178 178
179 return c.emit(d, func(w io.Writer) { 179 return c.emit(d, func(w io.Writer) {
180 section := func(title string, header []string, rows [][]cell) { 180 // At a terminal sections are separated by a blank line, and an
181 if c.Term.Cols > 0 { 181 // empty one is left out.
182 fmt.Fprintln(w, c.Term.paint(sgrBold, title)) 182 wrote := false
183 } else { 183 heading := func(title string) {
184 if c.Term.Cols == 0 {
184 fmt.Fprintln(w, title) 185 fmt.Fprintln(w, title)
186 return
187 }
188 if wrote {
189 fmt.Fprintln(w)
190 }
191 wrote = true
192 fmt.Fprintln(w, c.Term.heading(title))
193 }
194 section := func(title string, header []string, rows [][]cell) {
195 if c.Term.Cols > 0 && len(rows) == 0 {
196 return
185 } 197 }
198 heading(title)
186 if len(rows) == 0 { 199 if len(rows) == 0 {
187 fmt.Fprintln(w, " none") 200 fmt.Fprintln(w, " none")
188 return 201 return
@@ -238,7 +251,7 @@ func runDashboard(c *Ctx, args []string) int {
238 for i, p := range d.Pinned { 251 for i, p := range d.Pinned {
239 cells := []cell{cRef(p.Path), cState(p.Visibility), cFlex(p.Description)} 252 cells := []cell{cRef(p.Path), cState(p.Visibility), cFlex(p.Description)}
240 if p.Archived { 253 if p.Archived {
241 cells = append(cells, cText("[archived]")) 254 cells = c.note(cells, 1, "[archived]", "archived")
242 } 255 }
243 pinnedRows[i] = cells 256 pinnedRows[i] = cells
244 } 257 }
@@ -258,10 +271,11 @@ func runDashboard(c *Ctx, args []string) int {
258 section("builds:", []string{"REPO", "#", "JOB", "STATUS", "SHA", "REF"}, buildRows) 271 section("builds:", []string{"REPO", "#", "JOB", "STATUS", "SHA", "REF"}, buildRows)
259 272
260 if d.Server != nil { 273 if d.Server != nil {
261 fmt.Fprintf(w, "server:\n build %s\n", d.Server.Commit) 274 heading("server:")
275 fmt.Fprintf(w, " build %s\n", d.Server.Commit)
262 } 276 }
263 if q := d.Queues; q != nil { 277 if q := d.Queues; q != nil {
264 fmt.Fprintln(w, "queues:") 278 heading("queues:")
265 279
266 fmt.Fprintf(w, " webhooks\tpending %d\tretrying %d\tfailed %d\n", q.Webhooks.Pending, q.Webhooks.Retrying, q.Webhooks.Failed) 280 fmt.Fprintf(w, " webhooks\tpending %d\tretrying %d\tfailed %d\n", q.Webhooks.Pending, q.Webhooks.Retrying, q.Webhooks.Failed)
267 twh := c.table(w, "REPO", "URL", "ATTEMPTS", "ERROR") 281 twh := c.table(w, "REPO", "URL", "ATTEMPTS", "ERROR")
internal/control/deploykey.go +2 −2
@@ -84,7 +84,7 @@ func runDeployKeyAdd(c *Ctx, args []string) int {
84 return c.emit(d, func(w io.Writer) { 84 return c.emit(d, func(w io.Writer) {
85 line := fmt.Sprintf("deploy key %s (%s) bound to %s", fp, mode, repo.Path()) 85 line := fmt.Sprintf("deploy key %s (%s) bound to %s", fp, mode, repo.Path())
86 if expires != nil { 86 if expires != nil {
87 line += ", expires " + expiresText(expires, time.Now()) 87 line += ", expires " + c.expiresText(expires, time.Now())
88 } 88 }
89 fmt.Fprintln(w, line) 89 fmt.Fprintln(w, line)
90 }) 90 })
@@ -123,7 +123,7 @@ func runDeployKeyList(c *Ctx, args []string) int {
123 tb := c.table(w, "FINGERPRINT", "ALGO", "MODE", "LABEL", "USED", "EXPIRES") 123 tb := c.table(w, "FINGERPRINT", "ALGO", "MODE", "LABEL", "USED", "EXPIRES")
124 for _, d := range ds { 124 for _, d := range ds {
125 tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Mode), cText(d.Label), 125 tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Mode), cText(d.Label),
126 cText(c.usedText(d.LastUsedAt)), cText(expiresText(d.ExpiresAt, now))) 126 cText(c.usedText(d.LastUsedAt)), cText(c.expiresText(d.ExpiresAt, now)))
127 } 127 }
128 tb.flush() 128 tb.flush()
129 }) 129 })
internal/control/flags.go +45
@@ -80,6 +80,9 @@ func parseFlags(args []string, spec flagSpec) (flags, error) {
80 } 80 }
81 i++ 81 i++
82 default: 82 default:
83 if near := nearestFlag(a, kind); near != "" {
84 return f, usage("unknown flag %q; did you mean %s?", a, near)
85 }
83 return f, usage("unknown flag %q", a) 86 return f, usage("unknown flag %q", a)
84 } 87 }
85 continue 88 continue
@@ -92,6 +95,48 @@ func parseFlags(args []string, spec flagSpec) (flags, error) {
92 return f, nil 95 return f, nil
93} 96}
94 97
98// nearestFlag is the known flag closest to an unknown one: the only
99// flag it is a prefix of, or else the only one within two edits.
100func nearestFlag(a string, known map[string]byte) string {
101 var prefixed, close []string
102 for n := range known {
103 if strings.HasPrefix(n, a) {
104 prefixed = append(prefixed, n)
105 }
106 if editDistance(a, n) <= 2 {
107 close = append(close, n)
108 }
109 }
110 switch {
111 case len(prefixed) == 1:
112 return prefixed[0]
113 case len(prefixed) == 0 && len(close) == 1:
114 return close[0]
115 }
116 return ""
117}
118
119// editDistance is the Levenshtein distance between two ASCII strings.
120func editDistance(a, b string) int {
121 prev := make([]int, len(b)+1)
122 cur := make([]int, len(b)+1)
123 for j := range prev {
124 prev[j] = j
125 }
126 for i := 1; i <= len(a); i++ {
127 cur[0] = i
128 for j := 1; j <= len(b); j++ {
129 cost := 1
130 if a[i-1] == b[j-1] {
131 cost = 0
132 }
133 cur[j] = min(prev[j]+1, cur[j-1]+1, prev[j-1]+cost)
134 }
135 prev, cur = cur, prev
136 }
137 return prev[len(b)]
138}
139
95// parseArgs is parseFlags for the running command, with the usage line 140// parseArgs is parseFlags for the running command, with the usage line
96// printed the way a usage refusal prints it (cmdUsage): the program in 141// printed the way a usage refusal prints it (cmdUsage): the program in
97// front and the CLI's own path where it differs (#267). spec.Usage stays 142// front and the CLI's own path where it differs (#267). spec.Usage stays
internal/control/flags_test.go +16
@@ -31,3 +31,19 @@ func TestParseFlags(t *testing.T) {
31 t.Fatalf("-- handling: %v %+v", err, f) 31 t.Fatalf("-- handling: %v %+v", err, f)
32 } 32 }
33} 33}
34
35func TestNearestFlag(t *testing.T) {
36 known := map[string]byte{"--state": 'v', "--label": 'm', "--limit": 'v', "--json": 'b'}
37 cases := map[string]string{
38 "--stat": "--state",
39 "--lable": "--label",
40 "--l": "",
41 "--jsno": "--json",
42 "--nothing": "",
43 }
44 for in, want := range cases {
45 if got := nearestFlag(in, known); got != want {
46 t.Errorf("nearestFlag(%q) = %q, want %q", in, got, want)
47 }
48 }
49}
internal/control/identity.go +8 −5
@@ -102,7 +102,7 @@ func runKeysList(c *Ctx, args []string) int {
102 tb := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LABEL", "USED", "EXPIRES") 102 tb := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LABEL", "USED", "EXPIRES")
103 for _, d := range ds { 103 for _, d := range ds {
104 tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Scope), cText(d.Label), 104 tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Scope), cText(d.Label),
105 cText(c.usedText(d.LastUsedAt)), cText(expiresText(d.ExpiresAt, now))) 105 cText(c.usedText(d.LastUsedAt)), cText(c.expiresText(d.ExpiresAt, now)))
106 } 106 }
107 tb.flush() 107 tb.flush()
108 }) 108 })
@@ -138,13 +138,16 @@ func (c *Ctx) usedText(ts string) string {
138 return relAge(ts, termNow()) 138 return relAge(ts, termNow())
139} 139}
140 140
141// expiresText is a credential's expiry as an EXPIRES cell shows it. It 141// expiresText is a credential's expiry as an EXPIRES cell shows it:
142// is absolute at a terminal too: relAge reads only the past. 142// RFC3339 in plain output, relative at a terminal.
143func expiresText(t *time.Time, now time.Time) string { 143func (c *Ctx) expiresText(t *time.Time, now time.Time) string {
144 if t == nil { 144 if t == nil {
145 return "never" 145 return "never"
146 } 146 }
147 s := stamp(t.UTC().Format(time.RFC3339Nano)) 147 s := stamp(t.UTC().Format(time.RFC3339Nano))
148 if c.Term.Cols > 0 {
149 s = relAge(s, now)
150 }
148 if !t.After(now) { 151 if !t.After(now) {
149 return "expired " + s 152 return "expired " + s
150 } 153 }
@@ -204,7 +207,7 @@ func runKeysAdd(c *Ctx, args []string) int {
204 line += " " + d.Label 207 line += " " + d.Label
205 } 208 }
206 if d.ExpiresAt != nil { 209 if d.ExpiresAt != nil {
207 line += ", expires " + expiresText(d.ExpiresAt, time.Now()) 210 line += ", expires " + c.expiresText(d.ExpiresAt, time.Now())
208 } 211 }
209 fmt.Fprintln(w, line) 212 fmt.Fprintln(w, line)
210 }) 213 })
internal/control/profile.go +4 −4
@@ -265,7 +265,7 @@ func emitProfile(c *Ctx, d ProfileOut) int {
265 "activity", activity, 265 "activity", activity,
266 ) 266 )
267 if len(d.Links) > 0 { 267 if len(d.Links) > 0 {
268 v.section("link") 268 v.section("links")
269 tb := c.table(w, "LINK", "URL") 269 tb := c.table(w, "LINK", "URL")
270 for _, l := range d.Links { 270 for _, l := range d.Links {
271 tb.row(cText(l.Label), cFlex(l.URL)) 271 tb.row(cText(l.Label), cFlex(l.URL))
@@ -273,7 +273,7 @@ func emitProfile(c *Ctx, d ProfileOut) int {
273 tb.flush() 273 tb.flush()
274 } 274 }
275 if len(d.Orgs) > 0 { 275 if len(d.Orgs) > 0 {
276 v.section("org") 276 v.section("orgs")
277 tb := c.table(w, "ORG", "ROLE") 277 tb := c.table(w, "ORG", "ROLE")
278 for _, m := range d.Orgs { 278 for _, m := range d.Orgs {
279 tb.row(cRef(m.Name), cState(m.Role)) 279 tb.row(cRef(m.Name), cState(m.Role))
@@ -281,7 +281,7 @@ func emitProfile(c *Ctx, d ProfileOut) int {
281 tb.flush() 281 tb.flush()
282 } 282 }
283 if len(d.Members) > 0 { 283 if len(d.Members) > 0 {
284 v.section("member") 284 v.section("members")
285 tb := c.table(w, "MEMBER", "ROLE") 285 tb := c.table(w, "MEMBER", "ROLE")
286 for _, m := range d.Members { 286 for _, m := range d.Members {
287 tb.row(cRef(m.Name), cState(m.Role)) 287 tb.row(cRef(m.Name), cState(m.Role))
@@ -289,7 +289,7 @@ func emitProfile(c *Ctx, d ProfileOut) int {
289 tb.flush() 289 tb.flush()
290 } 290 }
291 if len(d.Repos) > 0 { 291 if len(d.Repos) > 0 {
292 v.section("repo") 292 v.section("repos")
293 tb := c.table(w, "REPO", "VISIBILITY", "DESCRIPTION") 293 tb := c.table(w, "REPO", "VISIBILITY", "DESCRIPTION")
294 for _, r := range d.Repos { 294 for _, r := range d.Repos {
295 tb.row(cRef(r.Path), cState(r.Visibility), cFlex(r.Description)) 295 tb.row(cRef(r.Path), cState(r.Visibility), cFlex(r.Description))
internal/control/read.go +5 −8
@@ -317,7 +317,11 @@ func runRepoTree(c *Ctx, args []string) int {
317 if e.Type == "tree" { 317 if e.Type == "tree" {
318 name += "/" 318 name += "/"
319 } 319 }
320 tb.row(cRef(e.SHA[:min(10, len(e.SHA))]), cText(sizeCol(e)), cFlex(name)) 320 size := cText("-")
321 if e.Type != "tree" {
322 size = cSize(e.Size)
323 }
324 tb.row(cRef(e.SHA[:min(10, len(e.SHA))]), size, cFlex(name))
321 } 325 }
322 tb.flush() 326 tb.flush()
323 }) 327 })
@@ -390,13 +394,6 @@ func runRepoReadme(c *Ctx, args []string) int {
390 }) 394 })
391} 395}
392 396
393func sizeCol(e entryOut) string {
394 if e.Type == "tree" {
395 return "-"
396 }
397 return fmt.Sprintf("%d", e.Size)
398}
399
400// readmeRank orders competing README files: richer renderers win. 397// readmeRank orders competing README files: richer renderers win.
401var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3} 398var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
402 399
internal/control/register.go +1 −1
@@ -82,7 +82,7 @@ func runEmailList(c *Ctx, args []string) int {
82 } 82 }
83 cells := []cell{cRef(d.Address), cState(state)} 83 cells := []cell{cRef(d.Address), cState(state)}
84 if d.Primary { 84 if d.Primary {
85 cells = append(cells, cText("primary")) 85 cells = c.note(cells, 1, "primary", "primary")
86 } 86 }
87 tb.row(cells...) 87 tb.row(cells...)
88 } 88 }
internal/control/release.go +1 −1
@@ -313,7 +313,7 @@ func runReleaseShow(c *Ctx, args []string) int {
313 v.section("assets") 313 v.section("assets")
314 tb := c.table(w, "NAME", "SIZE", "SHA256") 314 tb := c.table(w, "NAME", "SIZE", "SHA256")
315 for _, a := range d.Assets { 315 for _, a := range d.Assets {
316 tb.row(cRef(a.Name), cNum(a.Size), cFlex(a.SHA256)) 316 tb.row(cRef(a.Name), cSize(a.Size), cFlex(a.SHA256))
317 } 317 }
318 tb.flush() 318 tb.flush()
319 } 319 }
internal/control/repo.go +1 −1
@@ -365,7 +365,7 @@ func runRepoList(c *Ctx, args []string) int {
365 for _, d := range ds { 365 for _, d := range ds {
366 cells := []cell{cRef(d.Path), cState(d.Visibility), cFlex(d.Description)} 366 cells := []cell{cRef(d.Path), cState(d.Visibility), cFlex(d.Description)}
367 if d.Archived { 367 if d.Archived {
368 cells = append(cells, cText("[archived]")) 368 cells = c.note(cells, 1, "[archived]", "archived")
369 } 369 }
370 tb.row(cells...) 370 tb.row(cells...)
371 } 371 }
internal/control/table.go +50 −5
@@ -15,6 +15,7 @@ const (
15 kindState 15 kindState
16 kindAge 16 kindAge
17 kindNum 17 kindNum
18 kindSize
18) 19)
19 20
20// cell is one column of a table row. The kind decides colour, time 21// cell is one column of a table row. The kind decides colour, time
@@ -30,6 +31,7 @@ func cText(s string) cell { return cell{kindText, s} }
30func cFlex(s string) cell { return cell{kindFlex, s} } 31func cFlex(s string) cell { return cell{kindFlex, s} }
31func cAge(ts string) cell { return cell{kindAge, ts} } 32func cAge(ts string) cell { return cell{kindAge, ts} }
32func cNum(n int64) cell { return cell{kindNum, strconv.FormatInt(n, 10)} } 33func cNum(n int64) cell { return cell{kindNum, strconv.FormatInt(n, 10)} }
34func cSize(n int64) cell { return cell{kindSize, strconv.FormatInt(n, 10)} }
33 35
34// table is a list command's rows. Plain, each row is written as it 36// 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 37// comes, tab-separated with no header. At a terminal rows are held
@@ -42,6 +44,18 @@ type table struct {
42 rows [][]cell 44 rows [][]cell
43} 45}
44 46
47// note adds a marker to a row: its own trailing cell in plain output,
48// as rows have always carried it, and joined to the state cell at
49// cells[at] at a terminal ("private, archived"), so the table has no
50// unnamed column.
51func (c *Ctx) note(cells []cell, at int, plain, word string) []cell {
52 if c.Term.Cols == 0 {
53 return append(cells, cText(plain))
54 }
55 cells[at].s += ", " + word
56 return cells
57}
58
45func (c *Ctx) table(w io.Writer, header ...string) *table { 59func (c *Ctx) table(w io.Writer, header ...string) *table {
46 return &table{term: c.Term, w: w, header: header} 60 return &table{term: c.Term, w: w, header: header}
47} 61}
@@ -62,8 +76,13 @@ func (t *table) row(cs ...cell) {
62 now := termNow() 76 now := termNow()
63 for i := range cs { 77 for i := range cs {
64 cs[i].s = termSafe(cs[i].s) 78 cs[i].s = termSafe(cs[i].s)
65 if cs[i].kind == kindAge { 79 switch cs[i].kind {
80 case kindAge:
66 cs[i].s = relAge(cs[i].s, now) 81 cs[i].s = relAge(cs[i].s, now)
82 case kindSize:
83 if n, err := strconv.ParseInt(cs[i].s, 10, 64); err == nil {
84 cs[i].s = humanBytes(n)
85 }
67 } 86 }
68 } 87 }
69 t.rows = append(t.rows, cs) 88 t.rows = append(t.rows, cs)
@@ -89,6 +108,7 @@ func (t *table) flush() {
89 widths[i] = max(widths[i], cells(r[i].s)) 108 widths[i] = max(widths[i], cells(r[i].s))
90 } 109 }
91 } 110 }
111 t.capSparse(widths)
92 t.fit(widths) 112 t.fit(widths)
93 113
94 var b strings.Builder 114 var b strings.Builder
@@ -98,7 +118,7 @@ func (t *table) flush() {
98 line[i] = clip(t.header[i], widths[i]) 118 line[i] = clip(t.header[i], widths[i])
99 } 119 }
100 } 120 }
101 b.WriteString(t.term.paint(sgrDim, t.join(line, widths)) + "\n") 121 b.WriteString(t.term.paint(sgrDim, strings.TrimRight(t.join(line, widths), " ")) + "\n")
102 for _, r := range t.rows { 122 for _, r := range t.rows {
103 for i := 0; i < n; i++ { 123 for i := 0; i < n; i++ {
104 s := "" 124 s := ""
@@ -107,11 +127,31 @@ func (t *table) flush() {
107 } 127 }
108 line[i] = s 128 line[i] = s
109 } 129 }
110 b.WriteString(t.joinRow(r, line, widths) + "\n") 130 b.WriteString(strings.TrimRight(t.joinRow(r, line, widths), " ") + "\n")
111 } 131 }
112 io.WriteString(t.w, b.String()) 132 io.WriteString(t.w, b.String())
113} 133}
114 134
135// capSparse narrows a flexible column that is blank on most rows to a
136// third of the terminal, so a few long values do not push every other
137// row's later columns to the right edge.
138func (t *table) capSparse(widths []int) {
139 for i := range widths {
140 filled, flex := 0, false
141 for _, r := range t.rows {
142 if i < len(r) && r[i].kind == kindFlex {
143 flex = true
144 if r[i].s != "" {
145 filled++
146 }
147 }
148 }
149 if flex && filled*2 < len(t.rows) {
150 widths[i] = min(widths[i], max(8, t.term.Cols/3))
151 }
152 }
153}
154
115// fit shrinks columns until a row fits the terminal: the flexible 155// fit shrinks columns until a row fits the terminal: the flexible
116// column first, down to 8 cells, then the other text columns from the 156// column first, down to 8 cells, then the other text columns from the
117// right, down to 8 each. 157// right, down to 8 each.
@@ -177,8 +217,13 @@ func (t *table) joinRow(r []cell, line []string, widths []int) string {
177 if i < len(line)-1 { 217 if i < len(line)-1 {
178 padding = strings.Repeat(" ", max(0, widths[i]-cells(s))) 218 padding = strings.Repeat(" ", max(0, widths[i]-cells(s)))
179 } 219 }
180 if i < len(r) && r[i].kind == kindState { 220 if i < len(r) {
181 s = t.term.paint(stateColor(s), s) 221 switch r[i].kind {
222 case kindState:
223 s = t.term.paintState(s)
224 case kindRef:
225 s = t.term.paint(sgrCyan, s)
226 }
182 } 227 }
183 b.WriteString(s + padding) 228 b.WriteString(s + padding)
184 } 229 }
internal/control/table_test.go +47
@@ -124,3 +124,50 @@ func TestTableEmptyPrintsNothing(t *testing.T) {
124 t.Errorf("empty table printed %q", b.String()) 124 t.Errorf("empty table printed %q", b.String())
125 } 125 }
126} 126}
127
128func TestTableNoTrailingSpace(t *testing.T) {
129 var b bytes.Buffer
130 tb := (&Ctx{Term: Term{Cols: 80}}).table(&b, "ADDRESS", "STATE")
131 tb.row(cRef("a@example.com"), cState("verified"), cText("primary"))
132 tb.row(cRef("b@example.com"), cState("verified"))
133 tb.flush()
134 for _, line := range strings.Split(b.String(), "\n") {
135 if strings.HasSuffix(line, " ") {
136 t.Errorf("trailing space: %q", line)
137 }
138 }
139}
140
141// A flexible column blank on most rows is capped at a third of the
142// terminal.
143func TestTableCapsSparseFlex(t *testing.T) {
144 var b bytes.Buffer
145 tb := (&Ctx{Term: Term{Cols: 90}}).table(&b, "TAG", "TITLE", "ASSETS")
146 tb.row(cRef("v3"), cFlex(""), cText("2"))
147 tb.row(cRef("v2"), cFlex(""), cText("2"))
148 tb.row(cRef("v1"), cFlex(""), cText("2"))
149 tb.row(cRef("v0"), cFlex(strings.Repeat("x", 60)), cText("2"))
150 tb.flush()
151 lines := strings.Split(b.String(), "\n")
152 if at := strings.Index(lines[0], "ASSETS"); at != len("TAG ")+30+2 {
153 t.Errorf("ASSETS at %d:\n%s", at, b.String())
154 }
155}
156
157func TestTableSizeCells(t *testing.T) {
158 var plain, term bytes.Buffer
159 for _, c := range []struct {
160 ctx *Ctx
161 w *bytes.Buffer
162 }{{&Ctx{}, &plain}, {&Ctx{Term: Term{Cols: 80}}, &term}} {
163 tb := c.ctx.table(c.w, "NAME", "SIZE")
164 tb.row(cRef("a"), cSize(2048))
165 tb.flush()
166 }
167 if plain.String() != "a\t2048\n" {
168 t.Errorf("plain = %q", plain.String())
169 }
170 if term.String() != "NAME SIZE\na 2.0 KiB\n" {
171 t.Errorf("term = %q", term.String())
172 }
173}
internal/control/term.go +140 −20
@@ -19,21 +19,23 @@ type Term struct {
19 Color bool 19 Color bool
20} 20}
21 21
22// ParseTerm reads "<cols>[,color]". Anything else, or a width outside 22// ParseTerm reads "<cols>[,<option>]...". Options it does not know are
23// ignored, so a newer client's capabilities do not turn an older
24// server's output plain. A width that is not a number, or is outside
23// 40 to 1000, is plain output. 25// 40 to 1000, is plain output.
24func ParseTerm(v string) Term { 26func ParseTerm(v string) Term {
25 cols, opt, hasOpt := strings.Cut(v, ",") 27 parts := strings.Split(v, ",")
26 n, err := strconv.Atoi(cols) 28 n, err := strconv.Atoi(parts[0])
27 if err != nil || n < 40 || n > 1000 { 29 if err != nil || n < 40 || n > 1000 {
28 return Term{} 30 return Term{}
29 } 31 }
30 switch { 32 t := Term{Cols: n}
31 case !hasOpt: 33 for _, opt := range parts[1:] {
32 return Term{Cols: n} 34 if opt == "color" {
33 case opt == "color": 35 t.Color = true
34 return Term{Cols: n, Color: true} 36 }
35 } 37 }
36 return Term{} 38 return t
37} 39}
38 40
39const ( 41const (
@@ -42,7 +44,9 @@ const (
42 sgrDim = "\x1b[2m" 44 sgrDim = "\x1b[2m"
43 sgrRed = "\x1b[31m" 45 sgrRed = "\x1b[31m"
44 sgrGreen = "\x1b[32m" 46 sgrGreen = "\x1b[32m"
47 sgrYellow = "\x1b[33m"
45 sgrMagenta = "\x1b[35m" 48 sgrMagenta = "\x1b[35m"
49 sgrCyan = "\x1b[36m"
46) 50)
47 51
48// termSafe replaces the bytes a terminal would act on — ESC, the C0 52// termSafe replaces the bytes a terminal would act on — ESC, the C0
@@ -86,21 +90,107 @@ func (t Term) paint(sgr, s string) string {
86} 90}
87 91
88// stateColor maps a state word to the web's state tokens: --ok green, 92// stateColor maps a state word to the web's state tokens: --ok green,
89// --done magenta, --bad red, --neutral dim. 93// --done magenta, --bad red, --neutral dim, and yellow for what waits
94// on the viewer (the web's orange).
90func stateColor(s string) string { 95func stateColor(s string) string {
91 switch s { 96 switch s {
92 case "open", "success", "approved", "active": 97 case "open", "success", "approved", "active", "verified", "ok":
93 return sgrGreen 98 return sgrGreen
94 case "merged": 99 case "merged":
95 return sgrMagenta 100 return sgrMagenta
96 case "failed", "failure", "error", "changes requested": 101 case "failed", "failure", "error", "changes requested", "private",
102 "bad_signature", "signed_email_mismatch", "signed_key_expired", "signed_key_revoked":
97 return sgrRed 103 return sgrRed
98 case "closed", "draft", "pending", "canceled", "cancelled", "archived", "disabled": 104 case "closed", "draft", "pending", "canceled", "cancelled", "archived", "disabled",
105 "unsigned", "signed_unknown_key":
99 return sgrDim 106 return sgrDim
107 case "unverified":
108 return sgrYellow
100 } 109 }
101 return "" 110 return ""
102} 111}
103 112
113// paintState colours each word of a state cell: "private, archived"
114// is two states, each in its own colour.
115func (t Term) paintState(s string) string {
116 if !t.Color {
117 return s
118 }
119 words := strings.Split(s, ", ")
120 for i, w := range words {
121 words[i] = t.paint(stateColor(w), w)
122 }
123 return strings.Join(words, ", ")
124}
125
126// heading is a section label at a terminal: capitalised, no trailing
127// colon, bold.
128func (t Term) heading(label string) string {
129 label = strings.TrimSuffix(label, ":")
130 if r, size := utf8.DecodeRuneInString(label); size > 0 {
131 label = string(unicode.ToUpper(r)) + label[size:]
132 }
133 return t.paint(sgrBold, label)
134}
135
136// failure is a refusal as a terminal shows it: "error: " in red ahead
137// of the message, and a usage line wrapped to the width between its
138// bracketed groups, continuation lines indented under the command.
139func (t Term) failure(msg string) string {
140 lines := strings.Split(termSafe(msg), "\n")
141 for i, line := range lines {
142 if rest, ok := strings.CutPrefix(line, "usage: "); ok {
143 lines[i] = "usage: " + wrapUsage(rest, t.Cols-len("usage: "), strings.Repeat(" ", len("usage: ")))
144 } else if i == 0 {
145 lines[i] = t.paint(sgrBold+sgrRed, "error:") + " " + line
146 }
147 }
148 return strings.Join(lines, "\n")
149}
150
151// wrapUsage packs a usage line into lines of at most width cells,
152// breaking only between words outside brackets, so "[--state
153// open|closed|all]" and "[--label <l>]" are never split.
154func wrapUsage(u string, width int, indent string) string {
155 var words []string
156 depth, start := 0, 0
157 for i, r := range u {
158 switch r {
159 case '[', '<':
160 depth++
161 case ']', '>':
162 depth = max(0, depth-1)
163 case ' ':
164 if depth == 0 {
165 if i > start {
166 words = append(words, u[start:i])
167 }
168 start = i + 1
169 }
170 }
171 }
172 if start < len(u) {
173 words = append(words, u[start:])
174 }
175 var b strings.Builder
176 used := 0
177 for _, w := range words {
178 n := cells(w)
179 switch {
180 case used == 0:
181 case w == "|" || used+1+n > width:
182 b.WriteString("\n" + indent)
183 used = 0
184 default:
185 b.WriteByte(' ')
186 used++
187 }
188 b.WriteString(w)
189 used += n
190 }
191 return b.String()
192}
193
104// cells is the width of s in terminal cells: SGR sequences and 194// cells is the width of s in terminal cells: SGR sequences and
105// combining marks take none, East Asian wide and fullwidth runes two. 195// combining marks take none, East Asian wide and fullwidth runes two.
106func cells(s string) int { 196func cells(s string) int {
@@ -179,24 +269,54 @@ func stamp(s string) string {
179 return t.Format("2006-01-02T15:04:05Z") 269 return t.Format("2006-01-02T15:04:05Z")
180} 270}
181 271
182// relAge is a stored timestamp as a table shows it at a terminal. 272// relAge is a stored timestamp as a table shows it at a terminal: "2h
273// ago" in the past, "in 2h" in the future, a date beyond eight weeks
274// either way.
183func relAge(s string, now time.Time) string { 275func relAge(s string, now time.Time) string {
184 t, ok := parseStamp(s) 276 t, ok := parseStamp(s)
185 if !ok { 277 if !ok {
186 return s 278 return s
187 } 279 }
188 d := max(now.Sub(t), 0) 280 d := now.Sub(t)
281 future := d < 0
282 if future {
283 d = -d
284 }
285 var n string
189 switch { 286 switch {
190 case d < time.Minute: 287 case d < time.Minute:
191 return "just now" 288 return "just now"
192 case d < time.Hour: 289 case d < time.Hour:
193 return fmt.Sprintf("%dm ago", int(d/time.Minute)) 290 n = fmt.Sprintf("%dm", int(d/time.Minute))
194 case d < 24*time.Hour: 291 case d < 24*time.Hour:
195 return fmt.Sprintf("%dh ago", int(d/time.Hour)) 292 n = fmt.Sprintf("%dh", int(d/time.Hour))
196 case d < 14*24*time.Hour: 293 case d < 14*24*time.Hour:
197 return fmt.Sprintf("%dd ago", int(d/(24*time.Hour))) 294 n = fmt.Sprintf("%dd", int(d/(24*time.Hour)))
198 case d < 56*24*time.Hour: 295 case d < 56*24*time.Hour:
199 return fmt.Sprintf("%dw ago", int(d/(7*24*time.Hour))) 296 n = fmt.Sprintf("%dw", int(d/(7*24*time.Hour)))
297 default:
298 return t.Format("2006-01-02")
299 }
300 if future {
301 return "in " + n
302 }
303 return n + " ago"
304}
305
306// size is a byte count: the number in plain output, KiB and up at a
307// terminal.
308func (t Term) size(n int64) string {
309 if t.Cols == 0 {
310 return strconv.FormatInt(n, 10)
311 }
312 return humanBytes(n)
313}
314
315// dur is a number of seconds: "<n>s" in plain output, hours, minutes
316// and seconds at a terminal.
317func (t Term) dur(secs int64) string {
318 if t.Cols == 0 {
319 return fmt.Sprintf("%ds", secs)
200 } 320 }
201 return t.Format("2006-01-02") 321 return (time.Duration(secs) * time.Second).String()
202} 322}
internal/control/term_test.go +68 −10
@@ -7,15 +7,18 @@ import (
7 7
8func TestParseTerm(t *testing.T) { 8func TestParseTerm(t *testing.T) {
9 cases := map[string]Term{ 9 cases := map[string]Term{
10 "120": {Cols: 120}, 10 "120": {Cols: 120},
11 "120,color": {Cols: 120, Color: true}, 11 "120,color": {Cols: 120, Color: true},
12 "40": {Cols: 40}, 12 "40": {Cols: 40},
13 "39": {}, 13 "39": {},
14 "": {}, 14 "": {},
15 "abc": {}, 15 "abc": {},
16 "80,blink": {}, 16 "80,blink": {Cols: 80},
17 "80,": {}, 17 "80,": {Cols: 80},
18 "5000": {}, 18 "80,truecolor,color": {Cols: 80, Color: true},
19 "80,color,links": {Cols: 80, Color: true},
20 "abc,color": {},
21 "5000": {},
19 } 22 }
20 for in, want := range cases { 23 for in, want := range cases {
21 if got := ParseTerm(in); got != want { 24 if got := ParseTerm(in); got != want {
@@ -70,7 +73,10 @@ func TestStampAndRelAge(t *testing.T) {
70 "2026-09-20T12:00:00Z": "3d ago", 73 "2026-09-20T12:00:00Z": "3d ago",
71 "2026-09-02T12:00:00Z": "3w ago", 74 "2026-09-02T12:00:00Z": "3w ago",
72 "2026-06-01T12:00:00Z": "2026-06-01", 75 "2026-06-01T12:00:00Z": "2026-06-01",
73 "2026-09-24T12:00:00Z": "just now", 76 "2026-09-23T12:00:20Z": "just now",
77 "2026-09-23T17:00:00Z": "in 5h",
78 "2026-09-24T12:00:00Z": "in 1d",
79 "2027-09-24T12:00:00Z": "2027-09-24",
74 "not a time": "not a time", 80 "not a time": "not a time",
75 } 81 }
76 for in, want := range cases { 82 for in, want := range cases {
@@ -79,3 +85,55 @@ func TestStampAndRelAge(t *testing.T) {
79 } 85 }
80 } 86 }
81} 87}
88
89func TestSizeAndDur(t *testing.T) {
90 plain, term := Term{}, Term{Cols: 80}
91 if got := plain.size(3177346); got != "3177346" {
92 t.Errorf("plain size = %q", got)
93 }
94 if got := term.size(3177346); got != "3.0 MiB" {
95 t.Errorf("term size = %q", got)
96 }
97 if got := plain.dur(3223); got != "3223s" {
98 t.Errorf("plain dur = %q", got)
99 }
100 if got := term.dur(3223); got != "53m43s" {
101 t.Errorf("term dur = %q", got)
102 }
103}
104
105func TestPaintStateEachWord(t *testing.T) {
106 got := Term{Cols: 80, Color: true}.paintState("private, archived")
107 want := sgrRed + "private" + sgrReset + ", " + sgrDim + "archived" + sgrReset
108 if got != want {
109 t.Errorf("paintState = %q, want %q", got, want)
110 }
111 if got := (Term{Cols: 80}).paintState("private, archived"); got != "private, archived" {
112 t.Errorf("no colour = %q", got)
113 }
114}
115
116func TestHeading(t *testing.T) {
117 if got := (Term{Cols: 80}).heading("waiting on your review:"); got != "Waiting on your review" {
118 t.Errorf("heading = %q", got)
119 }
120}
121
122func TestFailureAtTerminal(t *testing.T) {
123 msg := "unknown flag \"--stat\"; did you mean --state?\nusage: gitbay issue list [<owner/name>] [--state open|closed|all] [--label <l>] [--assignee <user>]"
124 got := Term{Cols: 50}.failure(msg)
125 want := "error: unknown flag \"--stat\"; did you mean --state?\n" +
126 "usage: gitbay issue list [<owner/name>]\n" +
127 " [--state open|closed|all] [--label <l>]\n" +
128 " [--assignee <user>]"
129 if got != want {
130 t.Errorf("failure:\n%s\nwant\n%s", got, want)
131 }
132 alt := Term{Cols: 80}.failure("usage: gitbay issue list [--limit <n>] | issue list --query <name> | --q <query>")
133 if alt != "usage: gitbay issue list [--limit <n>]\n | issue list --query <name>\n | --q <query>" {
134 t.Errorf("alternatives:\n%s", alt)
135 }
136 if got := (Term{Cols: 80}).failure("usage: gitbay mr merge <n>"); got != "usage: gitbay mr merge <n>" {
137 t.Errorf("bare usage = %q", got)
138 }
139}
internal/control/token.go +1 −1
@@ -121,7 +121,7 @@ func runTokenList(c *Ctx, args []string) int {
121 return c.emit(ds, func(w io.Writer) { 121 return c.emit(ds, func(w io.Writer) {
122 tb := c.table(w, "NAME", "SCOPE", "EXPIRES") 122 tb := c.table(w, "NAME", "SCOPE", "EXPIRES")
123 for _, d := range ds { 123 for _, d := range ds {
124 tb.row(cRef(d.Name), cState(d.Scope), cText(expiresText(d.ExpiresAt, now))) 124 tb.row(cRef(d.Name), cState(d.Scope), cText(c.expiresText(d.ExpiresAt, now)))
125 } 125 }
126 tb.flush() 126 tb.flush()
127 }) 127 })
internal/control/view.go +5 −5
@@ -52,8 +52,8 @@ func (v *view) sep() {
52 v.wrote = true 52 v.wrote = true
53} 53}
54 54
55// section prints a sub-table's label: a blank line, then the label bold 55// section prints a sub-table's label: a blank line, then the label
56// at a terminal or "label:" in plain. Callers skip the call entirely 56// capitalised and bold at a terminal, or "label:" in plain. Callers skip the call entirely
57// when the table it introduces has no rows. 57// when the table it introduces has no rows.
58func (v *view) section(label string) { 58func (v *view) section(label string) {
59 v.sep() 59 v.sep()
@@ -61,7 +61,7 @@ func (v *view) section(label string) {
61 io.WriteString(v.w, label+":\n") 61 io.WriteString(v.w, label+":\n")
62 return 62 return
63 } 63 }
64 io.WriteString(v.w, v.c.Term.paint(sgrBold, label)+"\n") 64 io.WriteString(v.w, v.c.Term.heading(label)+"\n")
65} 65}
66 66
67// title prints "ref title state", wrapping title+state to the 67// title prints "ref title state", wrapping title+state to the
@@ -96,14 +96,14 @@ func (v *view) title(ref, title, state string) {
96 rest += state 96 rest += state
97 } 97 }
98 if rest == "" { 98 if rest == "" {
99 io.WriteString(v.w, ref+"\n") 99 io.WriteString(v.w, t.paint(sgrCyan, ref)+"\n")
100 return 100 return
101 } 101 }
102 lines := termtext.Wrap(rest, t.Cols-cells(prefix)) 102 lines := termtext.Wrap(rest, t.Cols-cells(prefix))
103 for i, line := range lines { 103 for i, line := range lines {
104 p := indent 104 p := indent
105 if i == 0 { 105 if i == 0 {
106 p = prefix 106 p = t.paint(sgrCyan, ref) + " "
107 } 107 }
108 if i < len(lines)-1 || state == "" { 108 if i < len(lines)-1 || state == "" {
109 io.WriteString(v.w, p+t.paint(sgrBold, line)+"\n") 109 io.WriteString(v.w, p+t.paint(sgrBold, line)+"\n")
internal/control/web.go +4 −6
@@ -42,13 +42,11 @@ func runWebSessionsList(c *Ctx, args []string) int {
42 return c.emit(sessions, func(w io.Writer) { 42 return c.emit(sessions, func(w io.Writer) {
43 tb := c.table(w, "ID", "SINCE", "UNTIL", "USED") 43 tb := c.table(w, "ID", "SINCE", "UNTIL", "USED")
44 for _, s := range sessions { 44 for _, s := range sessions {
45 since, until := s.CreatedAt, s.ExpiresAt 45 if c.Term.Cols > 0 {
46 if c.Term.Cols == 0 { 46 tb.row(cRef(s.ID), cAge(s.CreatedAt), cAge(s.ExpiresAt), cText(c.usedText(s.LastUsedAt)))
47 since, until = stamp(since), stamp(until) 47 continue
48 } else {
49 since, until = relAge(since, termNow()), relAge(until, termNow())
50 } 48 }
51 tb.row(cRef(s.ID), cText("since "+since), cText("until "+until), cText(c.usedText(s.LastUsedAt))) 49 tb.row(cRef(s.ID), cText("since "+stamp(s.CreatedAt)), cText("until "+stamp(s.ExpiresAt)), cText(c.usedText(s.LastUsedAt)))
52 } 50 }
53 tb.flush() 51 tb.flush()
54 }) 52 })