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 | instance older than v1.36.0, which refuses the argument as an unknown | 1055 | instance older than v1.36.0, which refuses the argument as an unknown |
| 1056 | command. | 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 | Stock ssh without the CLI's multiplexing gets the plain output unless | 1069 | Stock ssh without the CLI's multiplexing gets the plain output unless |
| 1059 | it passes the same leading argument or sets the environment variable | 1070 | it passes the same leading argument or sets the environment variable |
| 1060 | sshd is told to accept. OpenSSH parses options after the host, so the | 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 | 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 { | |||
| 114 | var noColor bool | 114 | var 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. | ||
| 120 | func termValue(isTerminal bool, cols int, env func(string) string) string { | 123 | func 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. | ||
| 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 | // stripNoColor removes --no-color wherever it appears. | 165 | // stripNoColor removes --no-color wherever it appears. |
| 132 | func stripNoColor(args []string) ([]string, bool) { | 166 | func 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. |
| 48 | var feedHeader = []string{"WHEN", "WHO", "EVENT", "WHERE", "DETAIL"} | 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 | 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. | ||
| 13 | func checksMark(sts []store.CommitStatus) cell { | 15 | func 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 | ||
| 30 | func cRef(s string) cell { return cell{kind: kindRef, s: s} } | 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 | func cNum(n int64) cell { return cell{kind: kindNum, s: strconv.FormatInt(n, 10)} } | 37 | func cNum(n int64) cell { return cell{kind: kindNum, s: strconv.FormatInt(n, 10)} } |
| 36 | func cSize(n int64) cell { return cell{kind: kindSize, s: strconv.FormatInt(n, 10)} } | 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 | // 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. |
| 40 | func cMark(s, sgr string) cell { return cell{kind: kindText, s: s, sgr: sgr} } | 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 | 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. | ||
| 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 | type Term struct { | 18 | type 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. | ||
| 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 | // 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. |
| 116 | func (t Term) paintState(s string) string { | 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 | "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 | |||
| 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 | } | ||