Commit 135f300c0b

135f300c0b4dcaf8233f962e714cdd7b9e97ee22

parent: a0aa68a7ca

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

cmc <hello@cleberg.net> · 2026-10-01 21:16 UTC

cli, control: truecolor label swatches and OSC 8 links

The CLI adds truecolor to --term when COLORTERM is truecolor or 24bit,
and links in terminals known to show OSC 8 (or GITBAY_LINKS=1;
GITBAY_LINKS=0 stops it). GITBAY_TERM=basic sends width and colour
only, for an instance older than these options.

The server renders a label's colour as a dot in that colour beside the
hex (cSwatch), and references in lists, search, the dashboard and the
feed as links to their page (cLink). Checks read with a glyph:
"✓ 3/3", "✗ 1 failed", "• 2 pending".

Closes #315

Layout: unified · split

.gitbay/wiki/Users.org +11
@@ -1055,6 +1055,17 @@ then prints:
1055instance older than v1.36.0, which refuses the argument as an unknown 1055instance older than v1.36.0, which refuses the argument as an unknown
1056command. 1056command.
1057 1057
1058Two 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
1060colour beside its hex in =label list=. =links= (sent in iTerm2, WezTerm,
1061Ghostty, VS Code, kitty and VTE terminals, or when =GITBAY_LINKS=1=;
1062=GITBAY_LINKS=0= stops it) makes references in lists open their page,
1063as OSC 8 hyperlinks. A terminal outside that list would print the
1064escape's text, so it is not sent there by default. =GITBAY_TERM=basic=
1065sends only the width and colour, for an instance older than the release
1066that added these options, which turns any option it does not know into
1067plain output.
1068
1058Stock ssh without the CLI's multiplexing gets the plain output unless 1069Stock ssh without the CLI's multiplexing gets the plain output unless
1059it passes the same leading argument or sets the environment variable 1070it passes the same leading argument or sets the environment variable
1060sshd is told to accept. OpenSSH parses options after the host, so the 1071sshd 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 key and scope; =auth keys list= marks this session's key; issue, merge 50 key and scope; =auth keys list= marks this session's key; issue, merge
51 request and release creation print the page's URL. A column empty on 51 request and release creation print the page's URL. A column empty on
52 every row is left out. 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* v1.40.1 — 2026-09-29 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 {
114var noColor bool 114var noColor bool
115 115
116// termValue is GITBAY_TERM for this invocation: the terminal's width, 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, 117// whether colour is wanted, and what else the terminal can show: 24-bit
118// so piped output stays the rows stock ssh prints, and when GITBAY_TERM 118// colour and hyperlinks. Empty when stdout is not a terminal, so piped
119// is "off", for an instance older than --term. 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.
120func termValue(isTerminal bool, cols int, env func(string) string) string { 123func termValue(isTerminal bool, cols int, env func(string) string) string {
121 if !isTerminal || cols < 40 || env("GITBAY_TERM") == "off" { 124 if !isTerminal || cols < 40 || env("GITBAY_TERM") == "off" {
122 return "" 125 return ""
123 } 126 }
124 v := strconv.Itoa(cols) 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 v += ",color" 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 return v 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.
147func 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// stripNoColor removes --no-color wherever it appears. 165// stripNoColor removes --no-color wherever it appears.
132func stripNoColor(args []string) ([]string, bool) { 166func stripNoColor(args []string) ([]string, bool) {
133 out := args[:0:0] 167 out := args[:0:0]
cmd/gitbay/term_test.go +8
@@ -26,6 +26,14 @@ func TestTermValue(t *testing.T) {
26 {true, 120, map[string]string{"TERM": "dumb"}, false, "120"}, 26 {true, 120, map[string]string{"TERM": "dumb"}, false, "120"},
27 {true, 120, nil, true, "120"}, 27 {true, 120, nil, true, "120"},
28 {true, 120, map[string]string{"GITBAY_TERM": "off"}, false, ""}, 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 for _, c := range cases { 38 for _, c := range cases {
31 noColor = c.noColor 39 noColor = c.noColor
internal/control/admin.go +1 −1
@@ -442,7 +442,7 @@ 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), 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 if d.Archived { 446 if d.Archived {
447 cells = c.note(cells, 1, "[archived]", "archived") 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 return c.emitPage(p, ds, next, func(w io.Writer) { 216 return c.emitPage(p, ds, next, func(w io.Writer) {
217 tb := c.table(w, "#", "JOB", "STATUS", "SHA", "REF", "TITLE") 217 tb := c.table(w, "#", "JOB", "STATUS", "SHA", "REF", "TITLE")
218 for _, d := range ds { 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 tb.flush() 221 tb.flush()
222 }) 222 })
internal/control/dashboard.go +10 −5
@@ -266,7 +266,12 @@ func runDashboard(c *Ctx, args []string) int {
266 itemRows := func(items []DashboardItem, marker string) [][]cell { 266 itemRows := func(items []DashboardItem, marker string) [][]cell {
267 rows := make([][]cell, len(items)) 267 rows := make([][]cell, len(items))
268 for i, item := range items { 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 return rows 276 return rows
272 } 277 }
@@ -296,7 +301,7 @@ func runDashboard(c *Ctx, args []string) int {
296 301
297 pinnedRows := make([][]cell, len(d.Pinned)) 302 pinnedRows := make([][]cell, len(d.Pinned))
298 for i, p := range d.Pinned { 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 if p.Archived { 305 if p.Archived {
301 cells = c.note(cells, 1, "[archived]", "archived") 306 cells = c.note(cells, 1, "[archived]", "archived")
302 } 307 }
@@ -309,7 +314,7 @@ func runDashboard(c *Ctx, args []string) int {
309 // The feed has its own command; the dashboard shows the start. 314 // The feed has its own command; the dashboard shows the start.
310 rows := make([][]cell, 0, dashboardActivity) 315 rows := make([][]cell, 0, dashboardActivity)
311 for _, l := range lines[:min(len(lines), dashboardActivity)] { 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 section("recent activity:", feedHeader, rows) 319 section("recent activity:", feedHeader, rows)
315 if len(lines) > dashboardActivity { 320 if len(lines) > dashboardActivity {
@@ -325,7 +330,7 @@ func runDashboard(c *Ctx, args []string) int {
325 330
326 buildRows := make([][]cell, len(d.Builds)) 331 buildRows := make([][]cell, len(d.Builds))
327 for i, b := range d.Builds { 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 section("builds:", []string{"REPO", "#", "JOB", "STATUS", "SHA", "REF"}, buildRows) 335 section("builds:", []string{"REPO", "#", "JOB", "STATUS", "SHA", "REF"}, buildRows)
331 336
@@ -440,7 +445,7 @@ func runFeed(c *Ctx, args []string) int {
440 if c.Term.Cols > 0 { 445 if c.Term.Cols > 0 {
441 tb := c.table(w, feedHeader...) 446 tb := c.table(w, feedHeader...)
442 for _, l := range lines { 447 for _, l := range lines {
443 tb.row(l.termCells()...) 448 tb.row(l.termCells(c)...)
444 } 449 }
445 tb.flush() 450 tb.flush()
446 return 451 return
internal/control/feedline.go +2 −2
@@ -47,7 +47,7 @@ func (l FeedLine) Sentence() string {
47// reference can carry their colours. 47// reference can carry their colours.
48var feedHeader = []string{"WHEN", "WHO", "EVENT", "WHERE", "DETAIL"} 48var feedHeader = []string{"WHEN", "WHO", "EVENT", "WHERE", "DETAIL"}
49 49
50func (l FeedLine) termCells() []cell { 50func (l FeedLine) termCells(c *Ctx) []cell {
51 ref := l.Ref 51 ref := l.Ref
52 if ref != "" && ref[0] != '#' && ref[0] != '!' { 52 if ref != "" && ref[0] != '#' && ref[0] != '!' {
53 ref = " " + ref 53 ref = " " + ref
@@ -58,7 +58,7 @@ func (l FeedLine) termCells() []cell {
58 verb = strings.TrimSuffix(verb, " "+l.State) 58 verb = strings.TrimSuffix(verb, " "+l.State)
59 detail = cState(l.State) 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// FeedLines turns stored events into readable lines. An unknown kind 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 if k := comments[issues[i].ID]; k > 0 { 363 if k := comments[issues[i].ID]; k > 0 {
364 n = strconv.Itoa(k) 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 cText(labelsMark(labels[issues[i].ID])), assigneesMark(assignees[issues[i].ID], c.User.Username), 367 cText(labelsMark(labels[issues[i].ID])), assigneesMark(assignees[issues[i].ID], c.User.Username),
368 cText(n), cAge(issues[i].UpdatedAt)) 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 return c.emit(labels, func(w io.Writer) { 57 return c.emit(labels, func(w io.Writer) {
58 tb := c.table(w, "NAME", "COLOR", "ISSUES", "MRS") 58 tb := c.table(w, "NAME", "COLOR", "ISSUES", "MRS")
59 for _, l := range labels { 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 if l.Org { 61 if l.Org {
62 cells = append(cells, cText("org")) 62 cells = append(cells, cText("org"))
63 } 63 }
internal/control/marks.go +6 −4
@@ -9,7 +9,9 @@ import (
9) 9)
10 10
11// checksMark sums a commit's statuses for a list row at a terminal: 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.
13func checksMark(sts []store.CommitStatus) cell { 15func checksMark(sts []store.CommitStatus) cell {
14 failed, pending, passed := 0, 0, 0 16 failed, pending, passed := 0, 0, 0
15 for _, s := range sts { 17 for _, s := range sts {
@@ -26,11 +28,11 @@ func checksMark(sts []store.CommitStatus) cell {
26 case len(sts) == 0: 28 case len(sts) == 0:
27 return cText("") 29 return cText("")
28 case failed > 0: 30 case failed > 0:
29 return cMark(fmt.Sprintf("%d failed", failed), sgrRed) 31 return cMark(fmt.Sprintf("✗ %d failed", failed), sgrRed)
30 case pending > 0: 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// reviewMark is where review of an open merge request stands, by the 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 text, sgr string 19 text, sgr string
20 }{ 20 }{
21 {nil, "", ""}, 21 {nil, "", ""},
22 {st("success", "success", "skipped"), "3/3", sgrGreen}, 22 {st("success", "success", "skipped"), "✓ 3/3", sgrGreen},
23 {st("success", "pending"), "1 pending", sgrDim}, 23 {st("success", "pending"), "• 1 pending", sgrDim},
24 {st("success", "failure", "error", "pending"), "2 failed", sgrRed}, 24 {st("success", "failure", "error", "pending"), "✗ 2 failed", sgrRed},
25 } 25 }
26 for _, tc := range cases { 26 for _, tc := range cases {
27 if got := checksMark(tc.in); got.s != tc.text || got.sgr != tc.sgr { 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 ref += fmt.Sprintf(" (on !%d)", d.StackedOn.Number) 724 ref += fmt.Sprintf(" (on !%d)", d.StackedOn.Number)
725 } 725 }
726 m := mrs[i] 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 checks[m.ID], review[m.ID], cAge(m.UpdatedAt)) 728 checks[m.ID], review[m.ID], cAge(m.UpdatedAt))
729 } 729 }
730 tb.flush() 730 tb.flush()
internal/control/orglabel.go +1 −1
@@ -154,7 +154,7 @@ func runOrgLabelList(c *Ctx, args []string) int {
154 return c.emit(labels, func(w io.Writer) { 154 return c.emit(labels, func(w io.Writer) {
155 tb := c.table(w, "NAME", "COLOR", "ISSUES", "MRS") 155 tb := c.table(w, "NAME", "COLOR", "ISSUES", "MRS")
156 for _, l := range labels { 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 tb.flush() 159 tb.flush()
160 }) 160 })
internal/control/release.go +1 −1
@@ -297,7 +297,7 @@ func runReleaseList(c *Ctx, args []string) int {
297 default: 297 default:
298 assets = fmt.Sprintf("%d assets", n) 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 tb.flush() 302 tb.flush()
303 return 303 return
internal/control/repo.go +2 −2
@@ -363,7 +363,7 @@ func runRepoList(c *Ctx, args []string) int {
363 return c.emitPage(p, ds, next, func(w io.Writer) { 363 return c.emitPage(p, ds, next, func(w io.Writer) {
364 tb := c.table(w, "PATH", "VISIBILITY", "DESCRIPTION") 364 tb := c.table(w, "PATH", "VISIBILITY", "DESCRIPTION")
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{cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility), cFlex(d.Description)}
367 if d.Archived { 367 if d.Archived {
368 cells = c.note(cells, 1, "[archived]", "archived") 368 cells = c.note(cells, 1, "[archived]", "archived")
369 } 369 }
@@ -1089,7 +1089,7 @@ func runRepoSearch(c *Ctx, args []string) int {
1089 return c.emit(ds, func(w io.Writer) { 1089 return c.emit(ds, func(w io.Writer) {
1090 tb := c.table(w, "PATH", "VISIBILITY", "DESCRIPTION") 1090 tb := c.table(w, "PATH", "VISIBILITY", "DESCRIPTION")
1091 for _, d := range ds { 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 tb.flush() 1094 tb.flush()
1095 }) 1095 })
internal/control/search.go +7 −2
@@ -4,6 +4,7 @@ import (
4 "fmt" 4 "fmt"
5 "io" 5 "io"
6 "slices" 6 "slices"
7 "strconv"
7 8
8 "gitbay.org/gitbay/internal/gitutil" 9 "gitbay.org/gitbay/internal/gitutil"
9 "gitbay.org/gitbay/internal/protocol" 10 "gitbay.org/gitbay/internal/protocol"
@@ -118,12 +119,16 @@ func writeSearchTable(c *Ctx, w io.Writer, results []SearchResult) {
118 switch r.Kind { 119 switch r.Kind {
119 case "repo": 120 case "repo":
120 if c.Term.Cols > 0 { 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 } else { 123 } else {
123 tb.row(cText("repo"), cRef(r.Repo), cFlex(r.Title)) 124 tb.row(cText("repo"), cRef(r.Repo), cFlex(r.Title))
124 } 125 }
125 default: 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 tb.flush() 134 tb.flush()
internal/control/table.go +17 −1
@@ -17,6 +17,7 @@ const (
17 kindAge 17 kindAge
18 kindNum 18 kindNum
19 kindSize 19 kindSize
20 kindSwatch
20) 21)
21 22
22// cell is one column of a table row. The kind decides colour, time 23// cell is one column of a table row. The kind decides colour, time
@@ -25,6 +26,7 @@ type cell struct {
25 kind cellKind 26 kind cellKind
26 s string 27 s string
27 sgr string // colour for a kindText cell whose meaning is not its word 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
30func cRef(s string) cell { return cell{kind: kindRef, s: s} } 32func 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} }
35func cNum(n int64) cell { return cell{kind: kindNum, s: strconv.FormatInt(n, 10)} } 37func cNum(n int64) cell { return cell{kind: kindNum, s: strconv.FormatInt(n, 10)} }
36func cSize(n int64) cell { return cell{kind: kindSize, s: strconv.FormatInt(n, 10)} } 38func 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.
42func 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.
46func cSwatch(hex string) cell { return cell{kind: kindSwatch, s: hex} }
47
38// cMark is text coloured for what it says about the row rather than for 48// cMark is text coloured for what it says about the row rather than for
39// its word: "2 failed" red, "review requested" yellow. 49// its word: "2 failed" red, "review requested" yellow.
40func cMark(s, sgr string) cell { return cell{kind: kindText, s: s, sgr: sgr} } 50func cMark(s, sgr string) cell { return cell{kind: kindText, s: s, sgr: sgr} }
@@ -89,6 +99,10 @@ func (t *table) row(cs ...cell) {
89 if n, err := strconv.ParseInt(cs[i].s, 10, 64); err == nil { 99 if n, err := strconv.ParseInt(cs[i].s, 10, 64); err == nil {
90 cs[i].s = humanBytes(n) 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 t.rows = append(t.rows, cs) 108 t.rows = append(t.rows, cs)
@@ -258,7 +272,9 @@ func (t *table) joinRow(r []cell, line []string, widths []int) string {
258 case kindState: 272 case kindState:
259 s = t.term.paintState(s) 273 s = t.term.paintState(s)
260 case kindRef: 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 default: 278 default:
263 s = t.term.paint(r[i].sgr, s) 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 t.Errorf("plain = %q", plain.String()) 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.
195func 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 (
18type Term struct { 18type Term struct {
19 Cols int 19 Cols int
20 Color bool 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// ParseTerm reads "<cols>[,<option>]...". Options it does not know are 27// ParseTerm reads "<cols>[,<option>]...". Options it does not know are
@@ -32,10 +36,16 @@ func ParseTerm(v string) Term {
32 } 36 }
33 t := Term{Cols: n} 37 t := Term{Cols: n}
34 for _, opt := range parts[1:] { 38 for _, opt := range parts[1:] {
35 if opt == "color" { 39 switch opt {
40 case "color":
36 t.Color = true 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 return t 49 return t
40} 50}
41 51
@@ -111,6 +121,38 @@ func stateColor(s string) string {
111 return "" 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.
126func (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.
135func 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.
148func (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// paintState colours each word of a state cell: "private, archived" 156// paintState colours each word of a state cell: "private, archived"
115// is two states, each in its own colour. 157// is two states, each in its own colour.
116func (t Term) paintState(s string) string { 158func (t Term) paintState(s string) string {
internal/control/term_test.go +22 −2
@@ -16,8 +16,9 @@ func TestParseTerm(t *testing.T) {
16 "abc": {}, 16 "abc": {},
17 "80,blink": {Cols: 80}, 17 "80,blink": {Cols: 80},
18 "80,": {Cols: 80}, 18 "80,": {Cols: 80},
19 "80,truecolor,color": {Cols: 80, Color: true}, 19 "80,truecolor,color": {Cols: 80, Color: true, TrueColor: true},
20 "80,color,links": {Cols: 80, Color: true}, 20 "80,color,links": {Cols: 80, Color: true, Links: true},
21 "80,truecolor": {Cols: 80},
21 "abc,color": {}, 22 "abc,color": {},
22 "5000": {}, 23 "5000": {},
23 } 24 }
@@ -199,3 +200,22 @@ func TestBuildLog(t *testing.T) {
199 t.Errorf("escape reached the terminal: %q", got) 200 t.Errorf("escape reached the terminal: %q", got)
200 } 201 }
201} 202}
203
204func 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}