control: terminal rendering foundations !547
22 files changed, +647 −86
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 | |
docs/specs/2026-10-01-cli-terminal-output-design.md added +189
| @@ -0,0 +1,189 @@ | ||
| 1 | # CLI terminal output | |
| 2 | ||
| 3 | Status: accepted, 2026-10-01. Findings from a sweep of 51 read-only commands | |
| 4 | against gitbay.org at a 110×50 colour terminal (`TERM=xterm-256color`), | |
| 5 | plus error paths and piped output. | |
| 6 | ||
| 7 | ## How output works today | |
| 8 | ||
| 9 | The CLI sends `--term=<cols>[,color]` as the first server argument when | |
| 10 | stdout is a terminal. The server renders everything: `table` in | |
| 11 | `internal/control/table.go` (header, padding, fit-to-width, relative | |
| 12 | ages, colour on `kindState` cells), `view` in `view.go` (title line, | |
| 13 | aligned fields, rendered body, events, comments), helpers in `term.go`. | |
| 14 | Piped output is tab-separated rows with no header; `--json` is the | |
| 15 | contract. Both are correct and stay unchanged. | |
| 16 | ||
| 17 | The terminal mode exists but is thin. Colour applies only to state cells | |
| 18 | whose word appears in `stateColor`'s fixed list, and to dim headers and | |
| 19 | bold titles. Everything else renders as uncoloured text. | |
| 20 | ||
| 21 | ## Constraints | |
| 22 | ||
| 23 | - Plain and `--json` output are byte-identical before and after, except | |
| 24 | for the bug fixes marked **(plain)** below. | |
| 25 | - All rendering stays server-side in `internal/control`, so stock | |
| 26 | `ssh git@gitbay.org --term=120,color repo list` gets the same result | |
| 27 | as the CLI. No formatting in `cmd/gitbay`. | |
| 28 | - Colour is never the only signal: every coloured cell keeps its word. | |
| 29 | `NO_COLOR`, `TERM=dumb`, `--no-color` keep working. | |
| 30 | - Colour roles follow the stylesheet: green ok, magenta done, red bad, | |
| 31 | dim neutral, yellow for what wants the viewer (the web's orange), | |
| 32 | blue/cyan for refs (the web's accent, "what you can act on"). | |
| 33 | ||
| 34 | ## Findings | |
| 35 | ||
| 36 | ### Renderer-wide | |
| 37 | ||
| 38 | | # | Finding | Evidence | | |
| 39 | |---|---|---| | |
| 40 | | R1 | Only words in `stateColor`'s list are coloured. `public`/`private`, `verified`/`unsigned`, `full`/`runner` scopes, `admin`, `[archived]`, `primary` print plain. | `repo list`, `repo log`, `auth keys list`, `admin user list` | | |
| 41 | | R2 | `kindRef` exists but is never painted; refs (`krz/gitbay#12`, `!546`, SHAs, branch names) look like prose. | every list | | |
| 42 | | R3 | Future timestamps: `relAge` clamps negative durations to zero. Session expiry renders "until just now". | `web sessions list` | | |
| 43 | | R4 | Some timestamp columns are `cText`, so they stay RFC3339 at a terminal. | `auth token list` EXPIRES | | |
| 44 | | R5 | Durations in raw seconds. | `admin runners`: `wait avg 3223s`, `wait max 12886s` | | |
| 45 | | R6 | Sizes in raw bytes where `admin stats` already humanizes. | `repo tree`, `release show` assets (`3177346`) | | |
| 46 | | R7 | An optional trailing cell (`[archived]`, `primary`) adds an unnamed column: the header and every row get trailing whitespace, and the header has a blank title. | `repo list`, `auth email list` | | |
| 47 | | R8 | A flex column that is blank on most rows still takes its maximum width. | `release list`: 90-cell TITLE column, blank on every visible row | | |
| 48 | | R9 | Diffs are uncoloured. | `repo commit`, `mr diff`, `repo diff`, `mr range-diff` | | |
| 49 | | R10 | Errors print as bare stderr lines with no visual distinction; an unknown flag gets no suggestion; list-command usage is a single 200-character line. | `issue list --stat open` | | |
| 50 | | R11 | Section headings are inconsistent: `waiting on your review:` (lowercase, colon) on the dashboard; `link`/`org`/`repo` (singular) on `profile show`; `mirror` on `repo show`. The dashboard prints `none` under empty sections; other views skip them. | `dashboard`, `profile show`, `repo show` | | |
| 51 | ||
| 52 | ### Per command | |
| 53 | ||
| 54 | | Command | Finding | | |
| 55 | |---|---| | |
| 56 | | `dashboard` | Empty sections ("waiting on your review: none") come first. No summary of what needs the viewer. A failing build looks the same as the other rows apart from its state word. Recent activity repeats `feed` (19 rows). | | |
| 57 | | `mr list` | No draft marker, checks result, review state, mergeability or age, so the open-MR list cannot answer "what do I do next". | | |
| 58 | | `issue list` | No labels, assignee, comment count or updated age. | | |
| 59 | | `repo show` | The description renders as a bold title, and `public` has no colour. No clone URL, open issue/MR counts, latest release or last build. | | |
| 60 | | `repo log` | Author as `(Name <email>)` takes a third of the width. No age column. Signature state is uncoloured, so an unsigned commit does not stand out. | | |
| 61 | | `repo refs` | **(plain)** Tags sort lexically (`v1.10.0` before `v1.2.0`), oldest first; `gitutil.SortVersions` already does this for the web. The default branch is not marked. | | |
| 62 | | `repo tree` | The SHA column comes first and is rarely wanted. Directories are not distinguished except by `/` and `-`. | | |
| 63 | | `repo commit` | **(plain)** The subject prints twice: `message` is everything after the first blank line of the payload, which is the whole message. The signature state and checks are computed and included in the JSON but not printed. The date is RFC3339. | | |
| 64 | | `release list` | No date. `10 asset(s)`. See R8. | | |
| 65 | | `release show` | Byte sizes; full 64-hex SHA-256 per asset. | | |
| 66 | | `label list` | Colour shown as hex text. A label with no colour leaves the cell blank. | | |
| 67 | | `milestone list` | `due -` when there is no due date; progress as text. | | |
| 68 | | `build show` | No steps, failed step, or pointer to `build log`. No link to the MR it belongs to. | | |
| 69 | | `build log` | `$ step` lines are not distinguished from output. | | |
| 70 | | `feed`, dashboard activity | Event sentences are uncoloured: `build success` and `build failure` read the same at a glance. | | |
| 71 | | `audit` | DATA column is raw JSON. | | |
| 72 | | `admin runners` | Fingerprints truncated (`SHA256:b5HGR…`) while `auth keys list` and `repo runner list` print them in full. | | |
| 73 | | `auth keys list`, `web sessions list` | The key or session in use is not marked. | | |
| 74 | | `auth whoami` | Prints the username only; at a terminal it could add instance, key label and scope. | | |
| 75 | | `profile show` | The README heading repeats the tagline already on the title line. | | |
| 76 | | mutations | `created krz/gitbay#312` with no URL; the output rules allow a second line for something to copy. | | |
| 77 | ||
| 78 | Things that work and should stay: column fitting with `…`, relative | |
| 79 | ages, `more: gitbay … --cursor` on stderr, pager for show/diff/log, | |
| 80 | `nothing to list` on stderr, `mr show` checks sub-table, exit codes and | |
| 81 | the not-found/usage messages. | |
| 82 | ||
| 83 | ## Plan | |
| 84 | ||
| 85 | One issue per phase, one MR each, merged in order. | |
| 86 | ||
| 87 | ### Phase 1: renderer foundations (`term.go`, `table.go`, `view.go`) | |
| 88 | ||
| 89 | 1. Colour roles. Replace `stateColor`'s word switch with a role table | |
| 90 | (`ok`, `done`, `bad`, `neutral`, `warn`, `ref`) and add the missing | |
| 91 | words: visibility (`private` bad, `public` default), signature | |
| 92 | states from `internal/sig` (`verified` ok, `unsigned` and | |
| 93 | `signed_unknown_key` neutral, the other `signed_*` and | |
| 94 | `bad_signature` bad), `archived`, `primary`, | |
| 95 | `admin`, scopes. Add `sgrYellow`, `sgrCyan`. | |
| 96 | 2. Paint `kindRef` cells cyan (R2). | |
| 97 | 3. `relAge` handles the future: `in 5h`, `in 3w`, a date past 8 weeks | |
| 98 | (R3). Convert remaining timestamp `cText` cells to `cAge` (R4). | |
| 99 | 4. New cell kinds `cSize(bytes)` and `cDur(seconds)`; terminal renders | |
| 100 | `3.0 MiB` / `53m43s`, plain keeps the number (R5, R6). | |
| 101 | 5. Optional trailing cells move into a named column or merge into the | |
| 102 | state cell (`public, archived`); `join` never pads the last column | |
| 103 | and trims trailing spaces (R7). | |
| 104 | 6. A flex column blank on more than half its rows is capped at a third | |
| 105 | of the terminal width (R8). | |
| 106 | 7. One `section()` style: Title Case, bold, no colon, in every view; | |
| 107 | empty sections are skipped everywhere (R11). | |
| 108 | 8. Errors: at a terminal, `error:` prefix in red on stderr. Unknown | |
| 109 | flags get `did you mean --state?` from the registered usage (edit | |
| 110 | distance ≤ 2). Long usage wraps one alternative per line (R10). | |
| 111 | ||
| 112 | 9. `ParseTerm` reads `<cols>[,opt]...` and ignores options it does not | |
| 113 | know, so phase 4's capability tokens reach an instance that has | |
| 114 | phase 1 without turning its output plain. | |
| 115 | ||
| 116 | Tests: `table_test.go` and `term_test.go` cases per kind and role; | |
| 117 | plain output stays covered by the existing plain-mode table tests, | |
| 118 | since every change above is gated on `Term.Cols`. | |
| 119 | ||
| 120 | ### Phase 2: diffs and logs | |
| 121 | ||
| 122 | 1. A `diffPaint` helper for unified diffs at a terminal: file headers | |
| 123 | bold, `@@` cyan, `+` green, `-` red, the stat block's `+`/`-` bars | |
| 124 | coloured. Used by `repo commit`, `repo diff`, `mr diff`, | |
| 125 | `mr range-diff`. | |
| 126 | 2. `repo commit` as a view: title line with short SHA, subject and | |
| 127 | signature state; fields for author, date (`when`), signer, checks; | |
| 128 | body; coloured diff. Fix the duplicated subject in plain output | |
| 129 | **(plain)**. | |
| 130 | 3. `build log`: `$ step` lines bold at a terminal; the failing step's | |
| 131 | line red when the build failed. | |
| 132 | ||
| 133 | ### Phase 3: action-oriented content | |
| 134 | ||
| 135 | Each item adds columns or fields only at a terminal unless noted; plain | |
| 136 | columns stay as they are. | |
| 137 | ||
| 138 | 1. `dashboard`: a first line summarising what needs the viewer | |
| 139 | (`2 need you: 1 review requested, 1 failing build`, yellow); | |
| 140 | non-empty action sections first (review requested, assigned, your | |
| 141 | failing builds), then open MRs/issues, pinned, builds; activity cut | |
| 142 | to 8 rows with a pointer to `gitbay feed`. | |
| 143 | 2. `mr list`: DRAFT marker in the state cell, CHECKS (`3/3`, red on a | |
| 144 | failure), REVIEW (`approved`, `changes requested`, | |
| 145 | `review requested` in yellow when it is the viewer), UPDATED age. | |
| 146 | 3. `issue list`: LABELS (first two, `+n`), ASSIGNEE, comment count, | |
| 147 | UPDATED age. | |
| 148 | 4. `repo show`: description as a body line rather than the title; | |
| 149 | fields for clone URL (ssh), open issues/MRs, latest release, last | |
| 150 | default-branch build with state. | |
| 151 | 5. `repo log`: AUTHOR as name only, an AGE column, signature coloured. | |
| 152 | 6. `repo refs`: `SortVersions`, newest first, default branch marked | |
| 153 | **(plain: order only)**. | |
| 154 | 7. `repo tree`: NAME first, directories cyan, SIZE humanized, SHA last. | |
| 155 | 8. `release list`: DATE column, `10 assets`; `release show`: sizes | |
| 156 | humanized, SHA-256 cut to 12 at a terminal. | |
| 157 | 9. `build show`: steps with state and duration, the failing step | |
| 158 | highlighted, `gitbay build log <n>` hint on stderr, the MR it | |
| 159 | belongs to. | |
| 160 | 10. `feed`/activity: colour the outcome word and refs. | |
| 161 | 11. `milestone list`: blank due instead of `due -`, progress as | |
| 162 | `1/2` plus a percentage. | |
| 163 | 12. `audit`: DATA rendered as `key=value` pairs at a terminal. | |
| 164 | 13. `auth keys list`, `web sessions list`: mark the current key or | |
| 165 | session with `*` (as `mr revisions` does). | |
| 166 | 14. `auth whoami`: at a terminal, add instance, key label and scope. | |
| 167 | 15. Mutations: a second line with the web URL for anything with a page | |
| 168 | (issue, MR, release, repo, snippet, wiki page). | |
| 169 | 16. `admin runners`: full fingerprints, consistent with the other lists. | |
| 170 | ||
| 171 | ### Phase 4: terminal capabilities | |
| 172 | ||
| 173 | 1. Capability tokens in `--term`: `truecolor` for label swatches | |
| 174 | (`●` in the label's colour), `links` for OSC 8 hyperlinks on refs. | |
| 175 | Relies on phase 1's `ParseTerm` change being deployed first. | |
| 176 | 2. Glyphs for compact columns (`✓ ✗ ●`), words kept everywhere else. | |
| 177 | ||
| 178 | ## Decisions (2026-10-01) | |
| 179 | ||
| 180 | 1. Yellow (`warn`) marks only what needs the viewer's action. `private` | |
| 181 | is red. | |
| 182 | 2. R8: a mostly-blank flex column is capped. | |
| 183 | 3. Phase 4 is in scope. Swatches go on when the client reports | |
| 184 | `COLORTERM=truecolor` or `24bit`; links go on when the client's | |
| 185 | terminal is known to support OSC 8 (`TERM_PROGRAM` in iTerm.app, | |
| 186 | WezTerm, ghostty, vscode; `KITTY_WINDOW_ID`; `VTE_VERSION` >= 5000) | |
| 187 | or `GITBAY_LINKS=1`, and off with `GITBAY_LINKS=0`. | |
| 188 | 4. Per-row data for `mr list` / `issue list` comes from one batch | |
| 189 | query per page in `internal/store`. | |
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 | }) |