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 | ** At a terminal | 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 | the SSH command line when stdout is a terminal (OpenSSH's multiplexed | 1006 | the SSH command line when stdout is a terminal (OpenSSH's multiplexed |
| 1007 | sessions, which the CLI uses, do not forward a session's =SetEnv=). | 1007 | sessions, which the CLI uses, do not forward a session's =SetEnv=). |
| 1008 | The argument goes first: the server reads =--term=<v>= only as the | 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 | - 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. | ||
| 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 | // 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 | |||
| 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 | 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. |
| 143 | func expiresText(t *time.Time, now time.Time) string { | 143 | func (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 | ||
| 393 | func 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. |
| 401 | var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3} | 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 | 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} } | |||
| 30 | func cFlex(s string) cell { return cell{kindFlex, s} } | 31 | func cFlex(s string) cell { return cell{kindFlex, s} } |
| 31 | func cAge(ts string) cell { return cell{kindAge, ts} } | 32 | func cAge(ts string) cell { return cell{kindAge, ts} } |
| 32 | func cNum(n int64) cell { return cell{kindNum, strconv.FormatInt(n, 10)} } | 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 | // 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. | ||
| 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 | func (c *Ctx) table(w io.Writer, header ...string) *table { | 59 | func (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. | ||
| 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 | // 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 | |||
| 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 | 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. |
| 24 | func ParseTerm(v string) Term { | 26 | func 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 | ||
| 39 | const ( | 41 | const ( |
| @@ -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). | ||
| 90 | func stateColor(s string) string { | 95 | func 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. | ||
| 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 | // 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. |
| 106 | func cells(s string) int { | 196 | func 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. | ||
| 183 | func relAge(s string, now time.Time) string { | 275 | func 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. | ||
| 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 | func TestParseTerm(t *testing.T) { | 8 | func 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 | |||
| 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 | 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. |
| 58 | func (v *view) section(label string) { | 58 | func (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 | }) |