Commit 4837660d33
Verified · cmc ci/build: success ci/test: success
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 | 1003 | ** At a terminal |
| 1004 | 1004 | |
| 1005 | The =gitbay= CLI sends a leading =--term=<cols>[,color]= argument on | |
| 1005 | The =gitbay= CLI sends a leading =--term=<cols>[,<option>]...= argument on | |
| 1006 | 1006 | the SSH command line when stdout is a terminal (OpenSSH's multiplexed |
| 1007 | 1007 | sessions, which the CLI uses, do not forward a session's =SetEnv=). |
| 1008 | 1008 | The argument goes first: the server reads =--term=<v>= only as the |
| 1009 | first argument, and ignores it over HTTP. The server then prints: | |
| 1009 | first argument, and ignores it over HTTP. =color= is the one option | |
| 1010 | the server acts on; it ignores options it does not know. The server | |
| 1011 | then prints: | |
| 1010 | 1012 | |
| 1011 | 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 | |
| 1013 | =2h ago=, and the next page as a command on stderr (piped output | |
| 1014 | keeps a =next\t<cursor>= row instead); | |
| 1014 | description column is cut with =…= first; a title or description | |
| 1015 | column blank on most rows is held to a third of the width), with no | |
| 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 | 1027 | - =show= views with a title line, aligned fields, the body rendered |
| 1016 | 1028 | from markdown or org, one line per event, and comments under a rule; |
| 1017 | 1029 | a sub-table (labels, revisions, and the like) carries a section label |
| 1018 | 1030 | line in both terminal and piped output; timestamps as |
| 1019 | 1031 | =2026-09-23 23:26 UTC=. =wiki show= piped prints the page source |
| 1020 | 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 | 1036 | - help with flag descriptions, defaults, and examples; =gitbay --help= |
| 1022 | 1037 | groups commands under WORK, REPOSITORIES, YOU and INSTANCE; |
| 1023 | 1038 | =help --json= adds =flags= and =examples=. |
CHANGELOG.org +11
| @@ -16,6 +16,17 @@ anything beyond "replace the binary and restart" is needed. | ||
| 16 | 16 | draft controls to its author as well as to writers, matching the |
| 17 | 17 | commands: an author without write access could do it over SSH but had |
| 18 | 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 | 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 | 313 | cells := []cell{cRef(e.Address), cState(state)} |
| 314 | 314 | if e.Primary { |
| 315 | cells = append(cells, cText("primary")) | |
| 315 | cells = c.note(cells, 1, "primary", "primary") | |
| 316 | 316 | } |
| 317 | 317 | te.row(cells...) |
| 318 | 318 | } |
| @@ -442,9 +442,9 @@ func runAdminRepoList(c *Ctx, args []string) int { | ||
| 442 | 442 | return c.emitPage(p, ds, next, func(w io.Writer) { |
| 443 | 443 | tb := c.table(w, "PATH", "VISIBILITY", "BYTES", "CREATED", "LAST PUSH") |
| 444 | 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 | 446 | if d.Archived { |
| 447 | cells = append(cells, cText("[archived]")) | |
| 447 | cells = c.note(cells, 1, "[archived]", "archived") | |
| 448 | 448 | } |
| 449 | 449 | tb.row(cells...) |
| 450 | 450 | } |
| @@ -579,8 +579,8 @@ func runAdminRunners(c *Ctx, args []string) int { | ||
| 579 | 579 | v.fields( |
| 580 | 580 | "pending", fmt.Sprintf("%d", queue.Pending), |
| 581 | 581 | "claimed 24h", fmt.Sprintf("%d", queue.Claimed24h), |
| 582 | "wait avg", fmt.Sprintf("%ds", queue.ClaimWaitAvgS), | |
| 583 | "wait max", fmt.Sprintf("%ds", queue.ClaimWaitMaxS), | |
| 582 | "wait avg", c.Term.dur(queue.ClaimWaitAvgS), | |
| 583 | "wait max", c.Term.dur(queue.ClaimWaitMaxS), | |
| 584 | 584 | "reaped 24h", fmt.Sprintf("%d", queue.Reaped24h), |
| 585 | 585 | ) |
| 586 | 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 | 419 | enc.SetEscapeHTML(false) |
| 420 | 420 | enc.Encode(protocol.Envelope{ProtocolVersion: protocol.Version, Error: msg}) |
| 421 | 421 | } else { |
| 422 | if c.Term.Cols > 0 { | |
| 423 | msg = c.Term.failure(msg) | |
| 424 | } | |
| 422 | 425 | fmt.Fprintln(c.Stderr, msg) |
| 423 | 426 | } |
| 424 | 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 | 179 | return c.emit(d, func(w io.Writer) { |
| 180 | section := func(title string, header []string, rows [][]cell) { | |
| 181 | if c.Term.Cols > 0 { | |
| 182 | fmt.Fprintln(w, c.Term.paint(sgrBold, title)) | |
| 183 | } else { | |
| 180 | // At a terminal sections are separated by a blank line, and an | |
| 181 | // empty one is left out. | |
| 182 | wrote := false | |
| 183 | heading := func(title string) { | |
| 184 | if c.Term.Cols == 0 { | |
| 184 | 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 | 199 | if len(rows) == 0 { |
| 187 | 200 | fmt.Fprintln(w, " none") |
| 188 | 201 | return |
| @@ -238,7 +251,7 @@ func runDashboard(c *Ctx, args []string) int { | ||
| 238 | 251 | for i, p := range d.Pinned { |
| 239 | 252 | cells := []cell{cRef(p.Path), cState(p.Visibility), cFlex(p.Description)} |
| 240 | 253 | if p.Archived { |
| 241 | cells = append(cells, cText("[archived]")) | |
| 254 | cells = c.note(cells, 1, "[archived]", "archived") | |
| 242 | 255 | } |
| 243 | 256 | pinnedRows[i] = cells |
| 244 | 257 | } |
| @@ -258,10 +271,11 @@ func runDashboard(c *Ctx, args []string) int { | ||
| 258 | 271 | section("builds:", []string{"REPO", "#", "JOB", "STATUS", "SHA", "REF"}, buildRows) |
| 259 | 272 | |
| 260 | 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 | 277 | if q := d.Queues; q != nil { |
| 264 | fmt.Fprintln(w, "queues:") | |
| 278 | heading("queues:") | |
| 265 | 279 | |
| 266 | 280 | fmt.Fprintf(w, " webhooks\tpending %d\tretrying %d\tfailed %d\n", q.Webhooks.Pending, q.Webhooks.Retrying, q.Webhooks.Failed) |
| 267 | 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 | 84 | return c.emit(d, func(w io.Writer) { |
| 85 | 85 | line := fmt.Sprintf("deploy key %s (%s) bound to %s", fp, mode, repo.Path()) |
| 86 | 86 | if expires != nil { |
| 87 | line += ", expires " + expiresText(expires, time.Now()) | |
| 87 | line += ", expires " + c.expiresText(expires, time.Now()) | |
| 88 | 88 | } |
| 89 | 89 | fmt.Fprintln(w, line) |
| 90 | 90 | }) |
| @@ -123,7 +123,7 @@ func runDeployKeyList(c *Ctx, args []string) int { | ||
| 123 | 123 | tb := c.table(w, "FINGERPRINT", "ALGO", "MODE", "LABEL", "USED", "EXPIRES") |
| 124 | 124 | for _, d := range ds { |
| 125 | 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 | 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 | 81 | i++ |
| 82 | 82 | default: |
| 83 | if near := nearestFlag(a, kind); near != "" { | |
| 84 | return f, usage("unknown flag %q; did you mean %s?", a, near) | |
| 85 | } | |
| 83 | 86 | return f, usage("unknown flag %q", a) |
| 84 | 87 | } |
| 85 | 88 | continue |
| @@ -92,6 +95,48 @@ func parseFlags(args []string, spec flagSpec) (flags, error) { | ||
| 92 | 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. | |
| 100 | func 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. | |
| 120 | func 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 | 140 | // parseArgs is parseFlags for the running command, with the usage line |
| 96 | 141 | // printed the way a usage refusal prints it (cmdUsage): the program in |
| 97 | 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 | 31 | t.Fatalf("-- handling: %v %+v", err, f) |
| 32 | 32 | } |
| 33 | 33 | } |
| 34 | ||
| 35 | func 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 | 102 | tb := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LABEL", "USED", "EXPIRES") |
| 103 | 103 | for _, d := range ds { |
| 104 | 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 | 107 | tb.flush() |
| 108 | 108 | }) |
| @@ -138,13 +138,16 @@ func (c *Ctx) usedText(ts string) string { | ||
| 138 | 138 | return relAge(ts, termNow()) |
| 139 | 139 | } |
| 140 | 140 | |
| 141 | // expiresText is a credential's expiry as an EXPIRES cell shows it. It | |
| 142 | // is absolute at a terminal too: relAge reads only the past. | |
| 143 | func expiresText(t *time.Time, now time.Time) string { | |
| 141 | // expiresText is a credential's expiry as an EXPIRES cell shows it: | |
| 142 | // RFC3339 in plain output, relative at a terminal. | |
| 143 | func (c *Ctx) expiresText(t *time.Time, now time.Time) string { | |
| 144 | 144 | if t == nil { |
| 145 | 145 | return "never" |
| 146 | 146 | } |
| 147 | 147 | s := stamp(t.UTC().Format(time.RFC3339Nano)) |
| 148 | if c.Term.Cols > 0 { | |
| 149 | s = relAge(s, now) | |
| 150 | } | |
| 148 | 151 | if !t.After(now) { |
| 149 | 152 | return "expired " + s |
| 150 | 153 | } |
| @@ -204,7 +207,7 @@ func runKeysAdd(c *Ctx, args []string) int { | ||
| 204 | 207 | line += " " + d.Label |
| 205 | 208 | } |
| 206 | 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 | 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 | 265 | "activity", activity, |
| 266 | 266 | ) |
| 267 | 267 | if len(d.Links) > 0 { |
| 268 | v.section("link") | |
| 268 | v.section("links") | |
| 269 | 269 | tb := c.table(w, "LINK", "URL") |
| 270 | 270 | for _, l := range d.Links { |
| 271 | 271 | tb.row(cText(l.Label), cFlex(l.URL)) |
| @@ -273,7 +273,7 @@ func emitProfile(c *Ctx, d ProfileOut) int { | ||
| 273 | 273 | tb.flush() |
| 274 | 274 | } |
| 275 | 275 | if len(d.Orgs) > 0 { |
| 276 | v.section("org") | |
| 276 | v.section("orgs") | |
| 277 | 277 | tb := c.table(w, "ORG", "ROLE") |
| 278 | 278 | for _, m := range d.Orgs { |
| 279 | 279 | tb.row(cRef(m.Name), cState(m.Role)) |
| @@ -281,7 +281,7 @@ func emitProfile(c *Ctx, d ProfileOut) int { | ||
| 281 | 281 | tb.flush() |
| 282 | 282 | } |
| 283 | 283 | if len(d.Members) > 0 { |
| 284 | v.section("member") | |
| 284 | v.section("members") | |
| 285 | 285 | tb := c.table(w, "MEMBER", "ROLE") |
| 286 | 286 | for _, m := range d.Members { |
| 287 | 287 | tb.row(cRef(m.Name), cState(m.Role)) |
| @@ -289,7 +289,7 @@ func emitProfile(c *Ctx, d ProfileOut) int { | ||
| 289 | 289 | tb.flush() |
| 290 | 290 | } |
| 291 | 291 | if len(d.Repos) > 0 { |
| 292 | v.section("repo") | |
| 292 | v.section("repos") | |
| 293 | 293 | tb := c.table(w, "REPO", "VISIBILITY", "DESCRIPTION") |
| 294 | 294 | for _, r := range d.Repos { |
| 295 | 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 | 317 | if e.Type == "tree" { |
| 318 | 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 | 326 | tb.flush() |
| 323 | 327 | }) |
| @@ -390,13 +394,6 @@ func runRepoReadme(c *Ctx, args []string) int { | ||
| 390 | 394 | }) |
| 391 | 395 | } |
| 392 | 396 | |
| 393 | func sizeCol(e entryOut) string { | |
| 394 | if e.Type == "tree" { | |
| 395 | return "-" | |
| 396 | } | |
| 397 | return fmt.Sprintf("%d", e.Size) | |
| 398 | } | |
| 399 | ||
| 400 | 397 | // readmeRank orders competing README files: richer renderers win. |
| 401 | 398 | var 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 | 83 | cells := []cell{cRef(d.Address), cState(state)} |
| 84 | 84 | if d.Primary { |
| 85 | cells = append(cells, cText("primary")) | |
| 85 | cells = c.note(cells, 1, "primary", "primary") | |
| 86 | 86 | } |
| 87 | 87 | tb.row(cells...) |
| 88 | 88 | } |
internal/control/release.go +1 −1
| @@ -313,7 +313,7 @@ func runReleaseShow(c *Ctx, args []string) int { | ||
| 313 | 313 | v.section("assets") |
| 314 | 314 | tb := c.table(w, "NAME", "SIZE", "SHA256") |
| 315 | 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 | 318 | tb.flush() |
| 319 | 319 | } |
internal/control/repo.go +1 −1
| @@ -365,7 +365,7 @@ func runRepoList(c *Ctx, args []string) int { | ||
| 365 | 365 | for _, d := range ds { |
| 366 | 366 | cells := []cell{cRef(d.Path), cState(d.Visibility), cFlex(d.Description)} |
| 367 | 367 | if d.Archived { |
| 368 | cells = append(cells, cText("[archived]")) | |
| 368 | cells = c.note(cells, 1, "[archived]", "archived") | |
| 369 | 369 | } |
| 370 | 370 | tb.row(cells...) |
| 371 | 371 | } |
internal/control/table.go +50 −5
| @@ -15,6 +15,7 @@ const ( | ||
| 15 | 15 | kindState |
| 16 | 16 | kindAge |
| 17 | 17 | kindNum |
| 18 | kindSize | |
| 18 | 19 | ) |
| 19 | 20 | |
| 20 | 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} } | ||
| 30 | 31 | func cFlex(s string) cell { return cell{kindFlex, s} } |
| 31 | 32 | func cAge(ts string) cell { return cell{kindAge, ts} } |
| 32 | 33 | func cNum(n int64) cell { return cell{kindNum, strconv.FormatInt(n, 10)} } |
| 34 | func cSize(n int64) cell { return cell{kindSize, strconv.FormatInt(n, 10)} } | |
| 33 | 35 | |
| 34 | 36 | // table is a list command's rows. Plain, each row is written as it |
| 35 | 37 | // comes, tab-separated with no header. At a terminal rows are held |
| @@ -42,6 +44,18 @@ type table struct { | ||
| 42 | 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. | |
| 51 | func (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 | ||
| 45 | 59 | func (c *Ctx) table(w io.Writer, header ...string) *table { |
| 46 | 60 | return &table{term: c.Term, w: w, header: header} |
| 47 | 61 | } |
| @@ -62,8 +76,13 @@ func (t *table) row(cs ...cell) { | ||
| 62 | 76 | now := termNow() |
| 63 | 77 | for i := range cs { |
| 64 | 78 | cs[i].s = termSafe(cs[i].s) |
| 65 | if cs[i].kind == kindAge { | |
| 79 | switch cs[i].kind { | |
| 80 | case kindAge: | |
| 66 | 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 | 88 | t.rows = append(t.rows, cs) |
| @@ -89,6 +108,7 @@ func (t *table) flush() { | ||
| 89 | 108 | widths[i] = max(widths[i], cells(r[i].s)) |
| 90 | 109 | } |
| 91 | 110 | } |
| 111 | t.capSparse(widths) | |
| 92 | 112 | t.fit(widths) |
| 93 | 113 | |
| 94 | 114 | var b strings.Builder |
| @@ -98,7 +118,7 @@ func (t *table) flush() { | ||
| 98 | 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 | 122 | for _, r := range t.rows { |
| 103 | 123 | for i := 0; i < n; i++ { |
| 104 | 124 | s := "" |
| @@ -107,11 +127,31 @@ func (t *table) flush() { | ||
| 107 | 127 | } |
| 108 | 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 | 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. | |
| 138 | func (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 | 155 | // fit shrinks columns until a row fits the terminal: the flexible |
| 116 | 156 | // column first, down to 8 cells, then the other text columns from the |
| 117 | 157 | // right, down to 8 each. |
| @@ -177,8 +217,13 @@ func (t *table) joinRow(r []cell, line []string, widths []int) string { | ||
| 177 | 217 | if i < len(line)-1 { |
| 178 | 218 | padding = strings.Repeat(" ", max(0, widths[i]-cells(s))) |
| 179 | 219 | } |
| 180 | if i < len(r) && r[i].kind == kindState { | |
| 181 | s = t.term.paint(stateColor(s), s) | |
| 220 | if i < len(r) { | |
| 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 | 228 | b.WriteString(s + padding) |
| 184 | 229 | } |
internal/control/table_test.go +47
| @@ -124,3 +124,50 @@ func TestTableEmptyPrintsNothing(t *testing.T) { | ||
| 124 | 124 | t.Errorf("empty table printed %q", b.String()) |
| 125 | 125 | } |
| 126 | 126 | } |
| 127 | ||
| 128 | func 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. | |
| 143 | func 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 | ||
| 157 | func 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 | 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 | 25 | // 40 to 1000, is plain output. |
| 24 | 26 | func ParseTerm(v string) Term { |
| 25 | cols, opt, hasOpt := strings.Cut(v, ",") | |
| 26 | n, err := strconv.Atoi(cols) | |
| 27 | parts := strings.Split(v, ",") | |
| 28 | n, err := strconv.Atoi(parts[0]) | |
| 27 | 29 | if err != nil || n < 40 || n > 1000 { |
| 28 | 30 | return Term{} |
| 29 | 31 | } |
| 30 | switch { | |
| 31 | case !hasOpt: | |
| 32 | return Term{Cols: n} | |
| 33 | case opt == "color": | |
| 34 | return Term{Cols: n, Color: true} | |
| 32 | t := Term{Cols: n} | |
| 33 | for _, opt := range parts[1:] { | |
| 34 | if opt == "color" { | |
| 35 | t.Color = true | |
| 36 | } | |
| 35 | 37 | } |
| 36 | return Term{} | |
| 38 | return t | |
| 37 | 39 | } |
| 38 | 40 | |
| 39 | 41 | const ( |
| @@ -42,7 +44,9 @@ const ( | ||
| 42 | 44 | sgrDim = "\x1b[2m" |
| 43 | 45 | sgrRed = "\x1b[31m" |
| 44 | 46 | sgrGreen = "\x1b[32m" |
| 47 | sgrYellow = "\x1b[33m" | |
| 45 | 48 | sgrMagenta = "\x1b[35m" |
| 49 | sgrCyan = "\x1b[36m" | |
| 46 | 50 | ) |
| 47 | 51 | |
| 48 | 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 | 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). | |
| 90 | 95 | func stateColor(s string) string { |
| 91 | 96 | switch s { |
| 92 | case "open", "success", "approved", "active": | |
| 97 | case "open", "success", "approved", "active", "verified", "ok": | |
| 93 | 98 | return sgrGreen |
| 94 | 99 | case "merged": |
| 95 | 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 | 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 | 106 | return sgrDim |
| 107 | case "unverified": | |
| 108 | return sgrYellow | |
| 100 | 109 | } |
| 101 | 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. | |
| 115 | func (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. | |
| 128 | func (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. | |
| 139 | func (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. | |
| 154 | func 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 | 194 | // cells is the width of s in terminal cells: SGR sequences and |
| 105 | 195 | // combining marks take none, East Asian wide and fullwidth runes two. |
| 106 | 196 | func cells(s string) int { |
| @@ -179,24 +269,54 @@ func stamp(s string) string { | ||
| 179 | 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. | |
| 183 | 275 | func relAge(s string, now time.Time) string { |
| 184 | 276 | t, ok := parseStamp(s) |
| 185 | 277 | if !ok { |
| 186 | 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 | 286 | switch { |
| 190 | 287 | case d < time.Minute: |
| 191 | 288 | return "just now" |
| 192 | 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 | 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 | 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 | 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. | |
| 308 | func (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. | |
| 317 | func (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 | |
| 8 | 8 | func TestParseTerm(t *testing.T) { |
| 9 | 9 | cases := map[string]Term{ |
| 10 | "120": {Cols: 120}, | |
| 11 | "120,color": {Cols: 120, Color: true}, | |
| 12 | "40": {Cols: 40}, | |
| 13 | "39": {}, | |
| 14 | "": {}, | |
| 15 | "abc": {}, | |
| 16 | "80,blink": {}, | |
| 17 | "80,": {}, | |
| 18 | "5000": {}, | |
| 10 | "120": {Cols: 120}, | |
| 11 | "120,color": {Cols: 120, Color: true}, | |
| 12 | "40": {Cols: 40}, | |
| 13 | "39": {}, | |
| 14 | "": {}, | |
| 15 | "abc": {}, | |
| 16 | "80,blink": {Cols: 80}, | |
| 17 | "80,": {Cols: 80}, | |
| 18 | "80,truecolor,color": {Cols: 80, Color: true}, | |
| 19 | "80,color,links": {Cols: 80, Color: true}, | |
| 20 | "abc,color": {}, | |
| 21 | "5000": {}, | |
| 19 | 22 | } |
| 20 | 23 | for in, want := range cases { |
| 21 | 24 | if got := ParseTerm(in); got != want { |
| @@ -70,7 +73,10 @@ func TestStampAndRelAge(t *testing.T) { | ||
| 70 | 73 | "2026-09-20T12:00:00Z": "3d ago", |
| 71 | 74 | "2026-09-02T12:00:00Z": "3w ago", |
| 72 | 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 | 80 | "not a time": "not a time", |
| 75 | 81 | } |
| 76 | 82 | for in, want := range cases { |
| @@ -79,3 +85,55 @@ func TestStampAndRelAge(t *testing.T) { | ||
| 79 | 85 | } |
| 80 | 86 | } |
| 81 | 87 | } |
| 88 | ||
| 89 | func 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 | ||
| 105 | func 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 | ||
| 116 | func 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 | ||
| 122 | func 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 | 121 | return c.emit(ds, func(w io.Writer) { |
| 122 | 122 | tb := c.table(w, "NAME", "SCOPE", "EXPIRES") |
| 123 | 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 | 126 | tb.flush() |
| 127 | 127 | }) |
internal/control/view.go +5 −5
| @@ -52,8 +52,8 @@ func (v *view) sep() { | ||
| 52 | 52 | v.wrote = true |
| 53 | 53 | } |
| 54 | 54 | |
| 55 | // section prints a sub-table's label: a blank line, then the label bold | |
| 56 | // at a terminal or "label:" in plain. Callers skip the call entirely | |
| 55 | // section prints a sub-table's label: a blank line, then the label | |
| 56 | // capitalised and bold at a terminal, or "label:" in plain. Callers skip the call entirely | |
| 57 | 57 | // when the table it introduces has no rows. |
| 58 | 58 | func (v *view) section(label string) { |
| 59 | 59 | v.sep() |
| @@ -61,7 +61,7 @@ func (v *view) section(label string) { | ||
| 61 | 61 | io.WriteString(v.w, label+":\n") |
| 62 | 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 | 67 | // title prints "ref title state", wrapping title+state to the |
| @@ -96,14 +96,14 @@ func (v *view) title(ref, title, state string) { | ||
| 96 | 96 | rest += state |
| 97 | 97 | } |
| 98 | 98 | if rest == "" { |
| 99 | io.WriteString(v.w, ref+"\n") | |
| 99 | io.WriteString(v.w, t.paint(sgrCyan, ref)+"\n") | |
| 100 | 100 | return |
| 101 | 101 | } |
| 102 | 102 | lines := termtext.Wrap(rest, t.Cols-cells(prefix)) |
| 103 | 103 | for i, line := range lines { |
| 104 | 104 | p := indent |
| 105 | 105 | if i == 0 { |
| 106 | p = prefix | |
| 106 | p = t.paint(sgrCyan, ref) + " " | |
| 107 | 107 | } |
| 108 | 108 | if i < len(lines)-1 || state == "" { |
| 109 | 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 | 42 | return c.emit(sessions, func(w io.Writer) { |
| 43 | 43 | tb := c.table(w, "ID", "SINCE", "UNTIL", "USED") |
| 44 | 44 | for _, s := range sessions { |
| 45 | since, until := s.CreatedAt, s.ExpiresAt | |
| 46 | if c.Term.Cols == 0 { | |
| 47 | since, until = stamp(since), stamp(until) | |
| 48 | } else { | |
| 49 | since, until = relAge(since, termNow()), relAge(until, termNow()) | |
| 45 | if c.Term.Cols > 0 { | |
| 46 | tb.row(cRef(s.ID), cAge(s.CreatedAt), cAge(s.ExpiresAt), cText(c.usedText(s.LastUsedAt))) | |
| 47 | continue | |
| 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 | 51 | tb.flush() |
| 54 | 52 | }) |