Commit 135f300c0b
Verified · cmc ci/build: success ci/test: success
Layout: unified · split
.gitbay/wiki/Users.org +11
| @@ -1055,6 +1055,17 @@ then prints: | ||
| 1055 | 1055 | instance older than v1.36.0, which refuses the argument as an unknown |
| 1056 | 1056 | command. |
| 1057 | 1057 | |
| 1058 | Two more options say what the terminal can show. =truecolor= (sent when | |
| 1059 | =COLORTERM= is =truecolor= or =24bit=) puts a dot in each label's own | |
| 1060 | colour beside its hex in =label list=. =links= (sent in iTerm2, WezTerm, | |
| 1061 | Ghostty, VS Code, kitty and VTE terminals, or when =GITBAY_LINKS=1=; | |
| 1062 | =GITBAY_LINKS=0= stops it) makes references in lists open their page, | |
| 1063 | as OSC 8 hyperlinks. A terminal outside that list would print the | |
| 1064 | escape's text, so it is not sent there by default. =GITBAY_TERM=basic= | |
| 1065 | sends only the width and colour, for an instance older than the release | |
| 1066 | that added these options, which turns any option it does not know into | |
| 1067 | plain output. | |
| 1068 | ||
| 1058 | 1069 | Stock ssh without the CLI's multiplexing gets the plain output unless |
| 1059 | 1070 | it passes the same leading argument or sets the environment variable |
| 1060 | 1071 | sshd is told to accept. OpenSSH parses options after the host, so the |
CHANGELOG.org +9
| @@ -50,6 +50,15 @@ anything beyond "replace the binary and restart" is needed. | ||
| 50 | 50 | key and scope; =auth keys list= marks this session's key; issue, merge |
| 51 | 51 | request and release creation print the page's URL. A column empty on |
| 52 | 52 | every row is left out. |
| 53 | - The CLI tells the server what the terminal can show (#315): | |
| 54 | =truecolor= when =COLORTERM= is =truecolor= or =24bit=, for a dot in | |
| 55 | each label's own colour in =label list=, and =links= in iTerm2, | |
| 56 | WezTerm, Ghostty, VS Code, kitty and VTE terminals, or with | |
| 57 | =GITBAY_LINKS=1= (=0= turns it off), for references that open their | |
| 58 | page (OSC 8). Checks read =✓ 3/3=, =✗ 1 failed=, =• 2 pending=. | |
| 59 | *Upgrade note:* an instance older than this release turns those | |
| 60 | options into plain output; set =GITBAY_TERM=basic= for the CLI to send | |
| 61 | only the width and colour until the instance is upgraded. | |
| 53 | 62 | |
| 54 | 63 | * v1.40.1 — 2026-09-29 |
| 55 | 64 | |
cmd/gitbay/ssh.go +38 −4
| @@ -114,20 +114,54 @@ func sshArgs(inst cliconfig.Instance) []string { | ||
| 114 | 114 | var noColor bool |
| 115 | 115 | |
| 116 | 116 | // termValue is GITBAY_TERM for this invocation: the terminal's width, |
| 117 | // and whether colour is wanted. Empty when stdout is not a terminal, | |
| 118 | // so piped output stays the rows stock ssh prints, and when GITBAY_TERM | |
| 119 | // is "off", for an instance older than --term. | |
| 117 | // whether colour is wanted, and what else the terminal can show: 24-bit | |
| 118 | // colour and hyperlinks. Empty when stdout is not a terminal, so piped | |
| 119 | // output stays the rows stock ssh prints, and when GITBAY_TERM is "off", | |
| 120 | // for an instance older than --term. GITBAY_TERM=basic sends the width | |
| 121 | // and colour only, for an instance older than truecolor and links, which | |
| 122 | // turns any other option into plain output. | |
| 120 | 123 | func termValue(isTerminal bool, cols int, env func(string) string) string { |
| 121 | 124 | if !isTerminal || cols < 40 || env("GITBAY_TERM") == "off" { |
| 122 | 125 | return "" |
| 123 | 126 | } |
| 124 | 127 | v := strconv.Itoa(cols) |
| 125 | if !noColor && env("NO_COLOR") == "" && env("TERM") != "dumb" { | |
| 128 | color := !noColor && env("NO_COLOR") == "" && env("TERM") != "dumb" | |
| 129 | if color { | |
| 126 | 130 | v += ",color" |
| 127 | 131 | } |
| 132 | if env("GITBAY_TERM") == "basic" { | |
| 133 | return v | |
| 134 | } | |
| 135 | if ct := env("COLORTERM"); color && (ct == "truecolor" || ct == "24bit") { | |
| 136 | v += ",truecolor" | |
| 137 | } | |
| 138 | if linksWanted(env) { | |
| 139 | v += ",links" | |
| 140 | } | |
| 128 | 141 | return v |
| 129 | 142 | } |
| 130 | 143 | |
| 144 | // linksWanted reports whether the terminal shows OSC 8 hyperlinks: | |
| 145 | // GITBAY_LINKS=1 or 0 decides, else terminals known to support them. | |
| 146 | // One that does not would print the escape's text, so the default is no. | |
| 147 | func linksWanted(env func(string) string) bool { | |
| 148 | switch env("GITBAY_LINKS") { | |
| 149 | case "1": | |
| 150 | return true | |
| 151 | case "0": | |
| 152 | return false | |
| 153 | } | |
| 154 | switch env("TERM_PROGRAM") { | |
| 155 | case "iTerm.app", "WezTerm", "ghostty", "vscode": | |
| 156 | return true | |
| 157 | } | |
| 158 | if env("KITTY_WINDOW_ID") != "" { | |
| 159 | return true | |
| 160 | } | |
| 161 | n, err := strconv.Atoi(env("VTE_VERSION")) | |
| 162 | return err == nil && n >= 5000 | |
| 163 | } | |
| 164 | ||
| 131 | 165 | // stripNoColor removes --no-color wherever it appears. |
| 132 | 166 | func stripNoColor(args []string) ([]string, bool) { |
| 133 | 167 | out := args[:0:0] |
cmd/gitbay/term_test.go +8
| @@ -26,6 +26,14 @@ func TestTermValue(t *testing.T) { | ||
| 26 | 26 | {true, 120, map[string]string{"TERM": "dumb"}, false, "120"}, |
| 27 | 27 | {true, 120, nil, true, "120"}, |
| 28 | 28 | {true, 120, map[string]string{"GITBAY_TERM": "off"}, false, ""}, |
| 29 | {true, 120, map[string]string{"COLORTERM": "truecolor"}, false, "120,color,truecolor"}, | |
| 30 | {true, 120, map[string]string{"COLORTERM": "24bit", "NO_COLOR": "1"}, false, "120"}, | |
| 31 | {true, 120, map[string]string{"TERM_PROGRAM": "iTerm.app"}, false, "120,color,links"}, | |
| 32 | {true, 120, map[string]string{"TERM_PROGRAM": "iTerm.app", "GITBAY_LINKS": "0"}, false, "120,color"}, | |
| 33 | {true, 120, map[string]string{"TERM_PROGRAM": "Apple_Terminal"}, false, "120,color"}, | |
| 34 | {true, 120, map[string]string{"GITBAY_LINKS": "1"}, false, "120,color,links"}, | |
| 35 | {true, 120, map[string]string{"VTE_VERSION": "7200"}, false, "120,color,links"}, | |
| 36 | {true, 120, map[string]string{"COLORTERM": "truecolor", "GITBAY_LINKS": "1", "GITBAY_TERM": "basic"}, false, "120,color"}, | |
| 29 | 37 | } |
| 30 | 38 | for _, c := range cases { |
| 31 | 39 | noColor = c.noColor |
internal/control/admin.go +1 −1
| @@ -442,7 +442,7 @@ 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), cSize(d.Bytes), cAge(d.CreatedAt), cAge(d.LastPush)} | |
| 445 | cells := []cell{cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility), cSize(d.Bytes), cAge(d.CreatedAt), cAge(d.LastPush)} | |
| 446 | 446 | if d.Archived { |
| 447 | 447 | cells = c.note(cells, 1, "[archived]", "archived") |
| 448 | 448 | } |
internal/control/build.go +1 −1
| @@ -216,7 +216,7 @@ func runBuildList(c *Ctx, args []string) int { | ||
| 216 | 216 | return c.emitPage(p, ds, next, func(w io.Writer) { |
| 217 | 217 | tb := c.table(w, "#", "JOB", "STATUS", "SHA", "REF", "TITLE") |
| 218 | 218 | for _, d := range ds { |
| 219 | tb.row(cRef(fmt.Sprintf("%d", d.Number)), cText(d.Job), cState(d.Status), cRef(fmt.Sprintf("%.10s", d.SHA)), cText(d.Ref), cFlex(d.Subject)) | |
| 219 | tb.row(cLink(fmt.Sprintf("%d", d.Number), c.siteURL(repo.Path(), "builds", strconv.FormatInt(d.Number, 10))), cText(d.Job), cState(d.Status), cRef(fmt.Sprintf("%.10s", d.SHA)), cText(d.Ref), cFlex(d.Subject)) | |
| 220 | 220 | } |
| 221 | 221 | tb.flush() |
| 222 | 222 | }) |
internal/control/dashboard.go +10 −5
| @@ -266,7 +266,12 @@ func runDashboard(c *Ctx, args []string) int { | ||
| 266 | 266 | itemRows := func(items []DashboardItem, marker string) [][]cell { |
| 267 | 267 | rows := make([][]cell, len(items)) |
| 268 | 268 | for i, item := range items { |
| 269 | rows[i] = []cell{cRef(fmt.Sprintf("%s%s%d", item.Repo, marker, item.Number)), cFlex(item.Title), cText(item.Author)} | |
| 269 | page := "issues" | |
| 270 | if marker == "!" { | |
| 271 | page = "mrs" | |
| 272 | } | |
| 273 | ref := cLink(fmt.Sprintf("%s%s%d", item.Repo, marker, item.Number), c.siteURL(item.Repo, page, strconv.FormatInt(item.Number, 10))) | |
| 274 | rows[i] = []cell{ref, cFlex(item.Title), cText(item.Author)} | |
| 270 | 275 | } |
| 271 | 276 | return rows |
| 272 | 277 | } |
| @@ -296,7 +301,7 @@ func runDashboard(c *Ctx, args []string) int { | ||
| 296 | 301 | |
| 297 | 302 | pinnedRows := make([][]cell, len(d.Pinned)) |
| 298 | 303 | for i, p := range d.Pinned { |
| 299 | cells := []cell{cRef(p.Path), cState(p.Visibility), cFlex(p.Description)} | |
| 304 | cells := []cell{cLink(p.Path, c.siteURL(p.Path)), cState(p.Visibility), cFlex(p.Description)} | |
| 300 | 305 | if p.Archived { |
| 301 | 306 | cells = c.note(cells, 1, "[archived]", "archived") |
| 302 | 307 | } |
| @@ -309,7 +314,7 @@ func runDashboard(c *Ctx, args []string) int { | ||
| 309 | 314 | // The feed has its own command; the dashboard shows the start. |
| 310 | 315 | rows := make([][]cell, 0, dashboardActivity) |
| 311 | 316 | for _, l := range lines[:min(len(lines), dashboardActivity)] { |
| 312 | rows = append(rows, l.termCells()) | |
| 317 | rows = append(rows, l.termCells(c)) | |
| 313 | 318 | } |
| 314 | 319 | section("recent activity:", feedHeader, rows) |
| 315 | 320 | if len(lines) > dashboardActivity { |
| @@ -325,7 +330,7 @@ func runDashboard(c *Ctx, args []string) int { | ||
| 325 | 330 | |
| 326 | 331 | buildRows := make([][]cell, len(d.Builds)) |
| 327 | 332 | for i, b := range d.Builds { |
| 328 | buildRows[i] = []cell{cRef(b.Repo), cNum(b.Number), cText(b.Job), cState(b.Status), cRef(fmt.Sprintf("%.10s", b.SHA)), cText(b.Ref)} | |
| 333 | buildRows[i] = []cell{cLink(b.Repo, c.siteURL(b.Repo, "builds", strconv.FormatInt(b.Number, 10))), cNum(b.Number), cText(b.Job), cState(b.Status), cRef(fmt.Sprintf("%.10s", b.SHA)), cText(b.Ref)} | |
| 329 | 334 | } |
| 330 | 335 | section("builds:", []string{"REPO", "#", "JOB", "STATUS", "SHA", "REF"}, buildRows) |
| 331 | 336 | |
| @@ -440,7 +445,7 @@ func runFeed(c *Ctx, args []string) int { | ||
| 440 | 445 | if c.Term.Cols > 0 { |
| 441 | 446 | tb := c.table(w, feedHeader...) |
| 442 | 447 | for _, l := range lines { |
| 443 | tb.row(l.termCells()...) | |
| 448 | tb.row(l.termCells(c)...) | |
| 444 | 449 | } |
| 445 | 450 | tb.flush() |
| 446 | 451 | return |
internal/control/feedline.go +2 −2
| @@ -47,7 +47,7 @@ func (l FeedLine) Sentence() string { | ||
| 47 | 47 | // reference can carry their colours. |
| 48 | 48 | var feedHeader = []string{"WHEN", "WHO", "EVENT", "WHERE", "DETAIL"} |
| 49 | 49 | |
| 50 | func (l FeedLine) termCells() []cell { | |
| 50 | func (l FeedLine) termCells(c *Ctx) []cell { | |
| 51 | 51 | ref := l.Ref |
| 52 | 52 | if ref != "" && ref[0] != '#' && ref[0] != '!' { |
| 53 | 53 | ref = " " + ref |
| @@ -58,7 +58,7 @@ func (l FeedLine) termCells() []cell { | ||
| 58 | 58 | verb = strings.TrimSuffix(verb, " "+l.State) |
| 59 | 59 | detail = cState(l.State) |
| 60 | 60 | } |
| 61 | return []cell{cAge(l.When), cText(l.Actor), cText(verb), cRef(l.Repo + ref), detail} | |
| 61 | return []cell{cAge(l.When), cText(l.Actor), cText(verb), cLink(l.Repo+ref, c.siteURL(strings.TrimPrefix(l.URL, "/"))), detail} | |
| 62 | 62 | } |
| 63 | 63 | |
| 64 | 64 | // FeedLines turns stored events into readable lines. An unknown kind |
internal/control/issue.go +1 −1
| @@ -363,7 +363,7 @@ func runIssueList(c *Ctx, args []string) int { | ||
| 363 | 363 | if k := comments[issues[i].ID]; k > 0 { |
| 364 | 364 | n = strconv.Itoa(k) |
| 365 | 365 | } |
| 366 | tb.row(cRef(fmt.Sprintf("#%d", d.Number)), cState(d.State), cFlex(d.Title), | |
| 366 | tb.row(cLink(fmt.Sprintf("#%d", d.Number), c.siteURL(repo.Path(), "issues", strconv.FormatInt(d.Number, 10))), cState(d.State), cFlex(d.Title), | |
| 367 | 367 | cText(labelsMark(labels[issues[i].ID])), assigneesMark(assignees[issues[i].ID], c.User.Username), |
| 368 | 368 | cText(n), cAge(issues[i].UpdatedAt)) |
| 369 | 369 | } |
internal/control/label.go +1 −1
| @@ -57,7 +57,7 @@ func runLabelList(c *Ctx, args []string) int { | ||
| 57 | 57 | return c.emit(labels, func(w io.Writer) { |
| 58 | 58 | tb := c.table(w, "NAME", "COLOR", "ISSUES", "MRS") |
| 59 | 59 | for _, l := range labels { |
| 60 | cells := []cell{cRef(l.Name), cText(l.Color), cNum(l.Issues), cNum(l.MRs)} | |
| 60 | cells := []cell{cRef(l.Name), cSwatch(l.Color), cNum(l.Issues), cNum(l.MRs)} | |
| 61 | 61 | if l.Org { |
| 62 | 62 | cells = append(cells, cText("org")) |
| 63 | 63 | } |
internal/control/marks.go +6 −4
| @@ -9,7 +9,9 @@ import ( | ||
| 9 | 9 | ) |
| 10 | 10 | |
| 11 | 11 | // checksMark sums a commit's statuses for a list row at a terminal: |
| 12 | // "2 failed" red, "1 pending" dim, "3/3" green, blank with none. | |
| 12 | // "✗ 2 failed" red, "• 1 pending" dim, "✓ 3/3" green, blank with none. | |
| 13 | // The glyph leads so the column reads at a glance; the words stay for | |
| 14 | // a terminal without colour. | |
| 13 | 15 | func checksMark(sts []store.CommitStatus) cell { |
| 14 | 16 | failed, pending, passed := 0, 0, 0 |
| 15 | 17 | for _, s := range sts { |
| @@ -26,11 +28,11 @@ func checksMark(sts []store.CommitStatus) cell { | ||
| 26 | 28 | case len(sts) == 0: |
| 27 | 29 | return cText("") |
| 28 | 30 | case failed > 0: |
| 29 | return cMark(fmt.Sprintf("%d failed", failed), sgrRed) | |
| 31 | return cMark(fmt.Sprintf("✗ %d failed", failed), sgrRed) | |
| 30 | 32 | case pending > 0: |
| 31 | return cMark(fmt.Sprintf("%d pending", pending), sgrDim) | |
| 33 | return cMark(fmt.Sprintf("• %d pending", pending), sgrDim) | |
| 32 | 34 | } |
| 33 | return cMark(fmt.Sprintf("%d/%d", passed, len(sts)), sgrGreen) | |
| 35 | return cMark(fmt.Sprintf("✓ %d/%d", passed, len(sts)), sgrGreen) | |
| 34 | 36 | } |
| 35 | 37 | |
| 36 | 38 | // reviewMark is where review of an open merge request stands, by the |
internal/control/marks_test.go +3 −3
| @@ -19,9 +19,9 @@ func TestChecksMark(t *testing.T) { | ||
| 19 | 19 | text, sgr string |
| 20 | 20 | }{ |
| 21 | 21 | {nil, "", ""}, |
| 22 | {st("success", "success", "skipped"), "3/3", sgrGreen}, | |
| 23 | {st("success", "pending"), "1 pending", sgrDim}, | |
| 24 | {st("success", "failure", "error", "pending"), "2 failed", sgrRed}, | |
| 22 | {st("success", "success", "skipped"), "✓ 3/3", sgrGreen}, | |
| 23 | {st("success", "pending"), "• 1 pending", sgrDim}, | |
| 24 | {st("success", "failure", "error", "pending"), "✗ 2 failed", sgrRed}, | |
| 25 | 25 | } |
| 26 | 26 | for _, tc := range cases { |
| 27 | 27 | if got := checksMark(tc.in); got.s != tc.text || got.sgr != tc.sgr { |
internal/control/mr.go +1 −1
| @@ -724,7 +724,7 @@ func mrListTerm(c *Ctx, w io.Writer, repo store.Repo, mrs []store.MR, ds []mrOut | ||
| 724 | 724 | ref += fmt.Sprintf(" (on !%d)", d.StackedOn.Number) |
| 725 | 725 | } |
| 726 | 726 | m := mrs[i] |
| 727 | tb.row(cRef(fmt.Sprintf("!%d", d.Number)), cState(state), cFlex(d.Title), cText(ref), | |
| 727 | tb.row(cLink(fmt.Sprintf("!%d", d.Number), c.siteURL(repo.Path(), "mrs", strconv.FormatInt(d.Number, 10))), cState(state), cFlex(d.Title), cText(ref), | |
| 728 | 728 | checks[m.ID], review[m.ID], cAge(m.UpdatedAt)) |
| 729 | 729 | } |
| 730 | 730 | tb.flush() |
internal/control/orglabel.go +1 −1
| @@ -154,7 +154,7 @@ func runOrgLabelList(c *Ctx, args []string) int { | ||
| 154 | 154 | return c.emit(labels, func(w io.Writer) { |
| 155 | 155 | tb := c.table(w, "NAME", "COLOR", "ISSUES", "MRS") |
| 156 | 156 | for _, l := range labels { |
| 157 | tb.row(cRef(l.Name), cText(l.Color), cNum(l.Issues), cNum(l.MRs)) | |
| 157 | tb.row(cRef(l.Name), cSwatch(l.Color), cNum(l.Issues), cNum(l.MRs)) | |
| 158 | 158 | } |
| 159 | 159 | tb.flush() |
| 160 | 160 | }) |
internal/control/release.go +1 −1
| @@ -297,7 +297,7 @@ func runReleaseList(c *Ctx, args []string) int { | ||
| 297 | 297 | default: |
| 298 | 298 | assets = fmt.Sprintf("%d assets", n) |
| 299 | 299 | } |
| 300 | tb.row(cRef(d.Tag), cFlex(title), cText(assets), cAge(d.CreatedAt)) | |
| 300 | tb.row(cLink(d.Tag, c.siteURL(repo.Path(), "releases")), cFlex(title), cText(assets), cAge(d.CreatedAt)) | |
| 301 | 301 | } |
| 302 | 302 | tb.flush() |
| 303 | 303 | return |
internal/control/repo.go +2 −2
| @@ -363,7 +363,7 @@ func runRepoList(c *Ctx, args []string) int { | ||
| 363 | 363 | return c.emitPage(p, ds, next, func(w io.Writer) { |
| 364 | 364 | tb := c.table(w, "PATH", "VISIBILITY", "DESCRIPTION") |
| 365 | 365 | for _, d := range ds { |
| 366 | cells := []cell{cRef(d.Path), cState(d.Visibility), cFlex(d.Description)} | |
| 366 | cells := []cell{cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility), cFlex(d.Description)} | |
| 367 | 367 | if d.Archived { |
| 368 | 368 | cells = c.note(cells, 1, "[archived]", "archived") |
| 369 | 369 | } |
| @@ -1089,7 +1089,7 @@ func runRepoSearch(c *Ctx, args []string) int { | ||
| 1089 | 1089 | return c.emit(ds, func(w io.Writer) { |
| 1090 | 1090 | tb := c.table(w, "PATH", "VISIBILITY", "DESCRIPTION") |
| 1091 | 1091 | for _, d := range ds { |
| 1092 | tb.row(cRef(d.Path), cState(d.Visibility), cFlex(d.Description)) | |
| 1092 | tb.row(cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility), cFlex(d.Description)) | |
| 1093 | 1093 | } |
| 1094 | 1094 | tb.flush() |
| 1095 | 1095 | }) |
internal/control/search.go +7 −2
| @@ -4,6 +4,7 @@ import ( | ||
| 4 | 4 | "fmt" |
| 5 | 5 | "io" |
| 6 | 6 | "slices" |
| 7 | "strconv" | |
| 7 | 8 | |
| 8 | 9 | "gitbay.org/gitbay/internal/gitutil" |
| 9 | 10 | "gitbay.org/gitbay/internal/protocol" |
| @@ -118,12 +119,16 @@ func writeSearchTable(c *Ctx, w io.Writer, results []SearchResult) { | ||
| 118 | 119 | switch r.Kind { |
| 119 | 120 | case "repo": |
| 120 | 121 | if c.Term.Cols > 0 { |
| 121 | tb.row(cText("repo"), cRef(r.Repo), cState(""), cFlex(r.Title)) | |
| 122 | tb.row(cText("repo"), cLink(r.Repo, c.siteURL(r.Repo)), cState(""), cFlex(r.Title)) | |
| 122 | 123 | } else { |
| 123 | 124 | tb.row(cText("repo"), cRef(r.Repo), cFlex(r.Title)) |
| 124 | 125 | } |
| 125 | 126 | default: |
| 126 | tb.row(cText(r.Kind), cRef(fmt.Sprintf("%s%s%d", r.Repo, SearchMarker(r.Kind), r.Number)), cState(r.State), cFlex(r.Title)) | |
| 127 | page := "issues" | |
| 128 | if r.Kind == "mr" { | |
| 129 | page = "mrs" | |
| 130 | } | |
| 131 | tb.row(cText(r.Kind), cLink(fmt.Sprintf("%s%s%d", r.Repo, SearchMarker(r.Kind), r.Number), c.siteURL(r.Repo, page, strconv.FormatInt(r.Number, 10))), cState(r.State), cFlex(r.Title)) | |
| 127 | 132 | } |
| 128 | 133 | } |
| 129 | 134 | tb.flush() |
internal/control/table.go +17 −1
| @@ -17,6 +17,7 @@ const ( | ||
| 17 | 17 | kindAge |
| 18 | 18 | kindNum |
| 19 | 19 | kindSize |
| 20 | kindSwatch | |
| 20 | 21 | ) |
| 21 | 22 | |
| 22 | 23 | // cell is one column of a table row. The kind decides colour, time |
| @@ -25,6 +26,7 @@ type cell struct { | ||
| 25 | 26 | kind cellKind |
| 26 | 27 | s string |
| 27 | 28 | sgr string // colour for a kindText cell whose meaning is not its word |
| 29 | url string // the page a kindRef cell links to, when the terminal shows links | |
| 28 | 30 | } |
| 29 | 31 | |
| 30 | 32 | func cRef(s string) cell { return cell{kind: kindRef, s: s} } |
| @@ -35,6 +37,14 @@ func cAge(ts string) cell { return cell{kind: kindAge, s: ts} } | ||
| 35 | 37 | func cNum(n int64) cell { return cell{kind: kindNum, s: strconv.FormatInt(n, 10)} } |
| 36 | 38 | func cSize(n int64) cell { return cell{kind: kindSize, s: strconv.FormatInt(n, 10)} } |
| 37 | 39 | |
| 40 | // cLink is a reference that links to its page at a terminal that shows | |
| 41 | // links; url is a path on this instance or an absolute URL. | |
| 42 | func cLink(s, url string) cell { return cell{kind: kindRef, s: s, url: url} } | |
| 43 | ||
| 44 | // cSwatch is a label colour: a coloured dot before the hex at a terminal | |
| 45 | // with 24-bit colour. | |
| 46 | func cSwatch(hex string) cell { return cell{kind: kindSwatch, s: hex} } | |
| 47 | ||
| 38 | 48 | // cMark is text coloured for what it says about the row rather than for |
| 39 | 49 | // its word: "2 failed" red, "review requested" yellow. |
| 40 | 50 | func cMark(s, sgr string) cell { return cell{kind: kindText, s: s, sgr: sgr} } |
| @@ -89,6 +99,10 @@ func (t *table) row(cs ...cell) { | ||
| 89 | 99 | if n, err := strconv.ParseInt(cs[i].s, 10, 64); err == nil { |
| 90 | 100 | cs[i].s = humanBytes(n) |
| 91 | 101 | } |
| 102 | case kindSwatch: | |
| 103 | if t.term.TrueColor && rgb(cs[i].s) != "" { | |
| 104 | cs[i].s = "● " + cs[i].s | |
| 105 | } | |
| 92 | 106 | } |
| 93 | 107 | } |
| 94 | 108 | t.rows = append(t.rows, cs) |
| @@ -258,7 +272,9 @@ func (t *table) joinRow(r []cell, line []string, widths []int) string { | ||
| 258 | 272 | case kindState: |
| 259 | 273 | s = t.term.paintState(s) |
| 260 | 274 | case kindRef: |
| 261 | s = t.term.paint(sgrCyan, s) | |
| 275 | s = t.term.link(r[i].url, t.term.paint(sgrCyan, s)) | |
| 276 | case kindSwatch: | |
| 277 | s = t.term.swatch(s) | |
| 262 | 278 | default: |
| 263 | 279 | s = t.term.paint(r[i].sgr, s) |
| 264 | 280 | } |
internal/control/table_test.go +22
| @@ -189,3 +189,25 @@ func TestTableDropsEmptyColumns(t *testing.T) { | ||
| 189 | 189 | t.Errorf("plain = %q", plain.String()) |
| 190 | 190 | } |
| 191 | 191 | } |
| 192 | ||
| 193 | // A swatch's dot is counted in the column's width, so the next column | |
| 194 | // stays aligned. | |
| 195 | func TestTableSwatchAligned(t *testing.T) { | |
| 196 | var b bytes.Buffer | |
| 197 | tb := (&Ctx{Term: Term{Cols: 80, Color: true, TrueColor: true}}).table(&b, "NAME", "COLOR", "ISSUES") | |
| 198 | tb.row(cRef("bug"), cSwatch("#cf222e"), cNum(3)) | |
| 199 | tb.row(cRef("none"), cSwatch(""), cNum(1)) | |
| 200 | tb.flush() | |
| 201 | lines := strings.Split(stripSGR(b.String()), "\n") | |
| 202 | col := func(line, sub string) int { return cells(line[:strings.Index(line, sub)]) } | |
| 203 | if at := col(lines[0], "ISSUES"); at != col(lines[1], "3") || at != col(lines[2], "1") { | |
| 204 | t.Errorf("ISSUES not aligned:\n%s", stripSGR(b.String())) | |
| 205 | } | |
| 206 | var plain bytes.Buffer | |
| 207 | tb = (&Ctx{}).table(&plain, "NAME", "COLOR") | |
| 208 | tb.row(cRef("bug"), cSwatch("#cf222e")) | |
| 209 | tb.flush() | |
| 210 | if plain.String() != "bug\t#cf222e\n" { | |
| 211 | t.Errorf("plain = %q", plain.String()) | |
| 212 | } | |
| 213 | } | |
internal/control/term.go +43 −1
| @@ -18,6 +18,10 @@ import ( | ||
| 18 | 18 | type Term struct { |
| 19 | 19 | Cols int |
| 20 | 20 | Color bool |
| 21 | // TrueColor is 24-bit colour, for a label's own colour. | |
| 22 | TrueColor bool | |
| 23 | // Links is OSC 8 hyperlinks, for a reference's page. | |
| 24 | Links bool | |
| 21 | 25 | } |
| 22 | 26 | |
| 23 | 27 | // ParseTerm reads "<cols>[,<option>]...". Options it does not know are |
| @@ -32,10 +36,16 @@ func ParseTerm(v string) Term { | ||
| 32 | 36 | } |
| 33 | 37 | t := Term{Cols: n} |
| 34 | 38 | for _, opt := range parts[1:] { |
| 35 | if opt == "color" { | |
| 39 | switch opt { | |
| 40 | case "color": | |
| 36 | 41 | t.Color = true |
| 42 | case "truecolor": | |
| 43 | t.TrueColor = true | |
| 44 | case "links": | |
| 45 | t.Links = true | |
| 37 | 46 | } |
| 38 | 47 | } |
| 48 | t.TrueColor = t.TrueColor && t.Color | |
| 39 | 49 | return t |
| 40 | 50 | } |
| 41 | 51 | |
| @@ -111,6 +121,38 @@ func stateColor(s string) string { | ||
| 111 | 121 | return "" |
| 112 | 122 | } |
| 113 | 123 | |
| 124 | // link makes s a hyperlink to url when the terminal shows them (OSC 8). | |
| 125 | // A url with a control byte in it is left out rather than sent. | |
| 126 | func (t Term) link(url, s string) string { | |
| 127 | if !t.Links || url == "" || s == "" || strings.IndexFunc(url, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0 { | |
| 128 | return s | |
| 129 | } | |
| 130 | return "\x1b]8;;" + url + "\x1b\\" + s + "\x1b]8;;\x1b\\" | |
| 131 | } | |
| 132 | ||
| 133 | // rgb is the SGR sequence for a "#rrggbb" colour as a 24-bit | |
| 134 | // foreground, or "" when hex is not one. | |
| 135 | func rgb(hex string) string { | |
| 136 | var r, g, b int | |
| 137 | if len(hex) != 7 || hex[0] != '#' { | |
| 138 | return "" | |
| 139 | } | |
| 140 | if n, err := fmt.Sscanf(hex[1:], "%02x%02x%02x", &r, &g, &b); err != nil || n != 3 { | |
| 141 | return "" | |
| 142 | } | |
| 143 | return fmt.Sprintf("\x1b[38;2;%d;%d;%dm", r, g, b) | |
| 144 | } | |
| 145 | ||
| 146 | // swatch paints the dot of a "● #rrggbb" label colour cell in that | |
| 147 | // colour; anything else is returned as it is. | |
| 148 | func (t Term) swatch(s string) string { | |
| 149 | hex, ok := strings.CutPrefix(s, "● ") | |
| 150 | if !ok || !t.TrueColor || rgb(hex) == "" { | |
| 151 | return s | |
| 152 | } | |
| 153 | return t.paint(rgb(hex), "●") + " " + hex | |
| 154 | } | |
| 155 | ||
| 114 | 156 | // paintState colours each word of a state cell: "private, archived" |
| 115 | 157 | // is two states, each in its own colour. |
| 116 | 158 | func (t Term) paintState(s string) string { |
internal/control/term_test.go +22 −2
| @@ -16,8 +16,9 @@ func TestParseTerm(t *testing.T) { | ||
| 16 | 16 | "abc": {}, |
| 17 | 17 | "80,blink": {Cols: 80}, |
| 18 | 18 | "80,": {Cols: 80}, |
| 19 | "80,truecolor,color": {Cols: 80, Color: true}, | |
| 20 | "80,color,links": {Cols: 80, Color: true}, | |
| 19 | "80,truecolor,color": {Cols: 80, Color: true, TrueColor: true}, | |
| 20 | "80,color,links": {Cols: 80, Color: true, Links: true}, | |
| 21 | "80,truecolor": {Cols: 80}, | |
| 21 | 22 | "abc,color": {}, |
| 22 | 23 | "5000": {}, |
| 23 | 24 | } |
| @@ -199,3 +200,22 @@ func TestBuildLog(t *testing.T) { | ||
| 199 | 200 | t.Errorf("escape reached the terminal: %q", got) |
| 200 | 201 | } |
| 201 | 202 | } |
| 203 | ||
| 204 | func TestLinkAndSwatch(t *testing.T) { | |
| 205 | on := Term{Cols: 80, Color: true, TrueColor: true, Links: true} | |
| 206 | if got := on.link("https://x.test/a", "a"); got != "\x1b]8;;https://x.test/a\x1b\\a\x1b]8;;\x1b\\" { | |
| 207 | t.Errorf("link = %q", got) | |
| 208 | } | |
| 209 | if got := on.link("https://x.test/\x1b", "a"); got != "a" { | |
| 210 | t.Errorf("control byte in url = %q", got) | |
| 211 | } | |
| 212 | if got := (Term{Cols: 80, Color: true}).link("https://x.test/a", "a"); got != "a" { | |
| 213 | t.Errorf("without links = %q", got) | |
| 214 | } | |
| 215 | if got := on.swatch("● #cf222e"); got != "\x1b[38;2;207;34;46m●"+sgrReset+" #cf222e" { | |
| 216 | t.Errorf("swatch = %q", got) | |
| 217 | } | |
| 218 | if got := on.swatch("● #nothex"); got != "● #nothex" { | |
| 219 | t.Errorf("bad hex = %q", got) | |
| 220 | } | |
| 221 | } | |