internal/control/term.go
486 lines · 13325 bytes
38 symbols in this file
TermParseTermsgrResetsgrBoldsgrDimsgrRedsgrGreensgrYellowsgrBluesgrMagentasgrCyantermSafeTerm.safeTerm.paintstateColorglyphTerm.linkrgbTerm.swatchTerm.paintStateTerm.failurewrapUsageTerm.diffTerm.paintPreambleTerm.paintDiffLinerangePairtoolSGRTerm.buildLogcellsruneCellsclippadtermNowparseStampstamprelAgeTerm.sizeTerm.dur
1package control
2
3import (
4 "fmt"
5 "regexp"
6 "strconv"
7 "strings"
8 "time"
9 "unicode"
10 "unicode/utf8"
11
12 "golang.org/x/text/width"
13)
14
15// Term is what the client said about its terminal (GITBAY_TERM). The
16// zero value is plain output: tab-separated rows, no header, no colour,
17// which is what stock ssh, the API and the web get.
18type Term struct {
19 Cols int
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
25 // Here is the repository the CLI inferred from the clone it runs in,
26 // so a suggested command can leave it out.
27 Here string
28}
29
30// ParseTerm reads "<cols>[,<option>]...". Options it does not know are
31// ignored, so a newer client's capabilities do not turn an older
32// server's output plain. A width that is not a number, or is outside
33// 40 to 1000, is plain output.
34func ParseTerm(v string) Term {
35 parts := strings.Split(v, ",")
36 n, err := strconv.Atoi(parts[0])
37 if err != nil || n < 40 || n > 1000 {
38 return Term{}
39 }
40 t := Term{Cols: n}
41 for _, opt := range parts[1:] {
42 if v, ok := strings.CutPrefix(opt, "here="); ok {
43 t.Here = v
44 continue
45 }
46 switch opt {
47 case "color":
48 t.Color = true
49 case "truecolor":
50 t.TrueColor = true
51 case "links":
52 t.Links = true
53 }
54 }
55 t.TrueColor = t.TrueColor && t.Color
56 return t
57}
58
59const (
60 sgrReset = "\x1b[0m"
61 sgrBold = "\x1b[1m"
62 sgrDim = "\x1b[2m"
63 sgrRed = "\x1b[31m"
64 sgrGreen = "\x1b[32m"
65 sgrYellow = "\x1b[33m"
66 sgrBlue = "\x1b[34m"
67 sgrMagenta = "\x1b[35m"
68 sgrCyan = "\x1b[36m"
69)
70
71// termSafe replaces the bytes a terminal would act on — ESC, the C0
72// controls but tab and newline, DEL, and the C1 controls — with U+FFFD,
73// so user text cannot move the cursor, set the clipboard (OSC 52) or
74// clear the screen. Carriage return is dropped rather than replaced:
75// web forms store CRLF line endings, and a lone CR would let text
76// overwrite its own line. Terminal output only: plain output is unchanged.
77func termSafe(s string) string {
78 unsafe := func(r rune) bool {
79 return (r < 0x20 && r != '\t' && r != '\n') || (r >= 0x7f && r <= 0x9f)
80 }
81 if strings.IndexFunc(s, unsafe) < 0 {
82 return s
83 }
84 return strings.Map(func(r rune) rune {
85 switch {
86 case r == '\r':
87 return -1
88 case unsafe(r):
89 return '\uFFFD'
90 }
91 return r
92 }, s)
93}
94
95// safe is termSafe at a terminal and s unchanged in plain output.
96func (t Term) safe(s string) string {
97 if t.Cols == 0 {
98 return s
99 }
100 return termSafe(s)
101}
102
103// paint wraps s in an SGR sequence when colour is on.
104func (t Term) paint(sgr, s string) string {
105 if !t.Color || sgr == "" || s == "" {
106 return s
107 }
108 return sgr + s + sgrReset
109}
110
111// stateColor maps a state word to the web's state tokens: --ok green,
112// --done magenta, --bad red, --neutral dim, and yellow for what waits
113// on the viewer (the web's orange).
114func stateColor(s string) string {
115 switch s {
116 case "open", "success", "approved", "active", "verified", "ok":
117 return sgrGreen
118 case "merged":
119 return sgrMagenta
120 case "failed", "failure", "error", "changes requested", "private",
121 "bad_signature", "signed_email_mismatch", "signed_key_expired", "signed_key_revoked":
122 return sgrRed
123 case "closed", "draft", "pending", "canceled", "cancelled", "archived", "disabled", "skipped",
124 "unsigned", "signed_unknown_key":
125 return sgrDim
126 case "unverified":
127 return sgrYellow
128 }
129 return ""
130}
131
132// glyph is the mark a screen puts first in a row or field for a state
133// word, and its colour: ✓ passed, ✗ failed or blocked, ◐ still going,
134// ○ closed or draft. A state with no mark ("open") returns "".
135func glyph(state string) (g, sgr string) {
136 switch state {
137 case "merged":
138 return "✓", ""
139 case "success", "ok", "approved", "verified", "passed":
140 return "✓", sgrGreen
141 case "failure", "failed", "error", "changes requested",
142 "bad_signature", "signed_email_mismatch", "signed_key_expired", "signed_key_revoked":
143 return "✗", sgrRed
144 case "pending", "running", "queued":
145 return "◐", ""
146 case "closed", "draft", "canceled", "cancelled", "skipped":
147 return "○", ""
148 }
149 return "", ""
150}
151
152// link makes s a hyperlink to url when the terminal shows them (OSC 8).
153// A url with a control byte in it is left out rather than sent.
154func (t Term) link(url, s string) string {
155 if !t.Links || url == "" || s == "" || strings.IndexFunc(url, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0 {
156 return s
157 }
158 return "\x1b]8;;" + url + "\x1b\\" + s + "\x1b]8;;\x1b\\"
159}
160
161// rgb is the SGR sequence for a "#rrggbb" colour as a 24-bit
162// foreground, or "" when hex is not one.
163func rgb(hex string) string {
164 var r, g, b int
165 if len(hex) != 7 || hex[0] != '#' {
166 return ""
167 }
168 if n, err := fmt.Sscanf(hex[1:], "%02x%02x%02x", &r, &g, &b); err != nil || n != 3 {
169 return ""
170 }
171 return fmt.Sprintf("\x1b[38;2;%d;%d;%dm", r, g, b)
172}
173
174// swatch paints the dot of a "● #rrggbb" label colour cell in that
175// colour; anything else is returned as it is.
176func (t Term) swatch(s string) string {
177 hex, ok := strings.CutPrefix(s, "● ")
178 if !ok || !t.TrueColor || rgb(hex) == "" {
179 return s
180 }
181 return t.paint(rgb(hex), "●") + " " + hex
182}
183
184// paintState colours each word of a state cell: "private, archived"
185// is two states, each in its own colour.
186func (t Term) paintState(s string) string {
187 if !t.Color {
188 return s
189 }
190 words := strings.Split(s, ", ")
191 for i, w := range words {
192 words[i] = t.paint(stateColor(w), w)
193 }
194 return strings.Join(words, ", ")
195}
196
197// failure is a refusal as a terminal shows it: "error: " in red ahead
198// of the message, and a usage line wrapped to the width between its
199// bracketed groups, continuation lines indented under the command.
200func (t Term) failure(msg string) string {
201 lines := strings.Split(termSafe(msg), "\n")
202 for i, line := range lines {
203 if rest, ok := strings.CutPrefix(line, "usage: "); ok {
204 lines[i] = "usage: " + wrapUsage(rest, t.Cols-len("usage: "), strings.Repeat(" ", len("usage: ")))
205 } else if i == 0 {
206 lines[i] = t.paint(sgrBold+sgrRed, "error:") + " " + line
207 }
208 }
209 return strings.Join(lines, "\n")
210}
211
212// wrapUsage packs a usage line into lines of at most width cells,
213// breaking only between words outside brackets, so "[--state
214// open|closed|all]" and "[--label <l>]" are never split.
215func wrapUsage(u string, width int, indent string) string {
216 var words []string
217 depth, start := 0, 0
218 for i, r := range u {
219 switch r {
220 case '[', '<':
221 depth++
222 case ']', '>':
223 depth = max(0, depth-1)
224 case ' ':
225 if depth == 0 {
226 if i > start {
227 words = append(words, u[start:i])
228 }
229 start = i + 1
230 }
231 }
232 }
233 if start < len(u) {
234 words = append(words, u[start:])
235 }
236 var b strings.Builder
237 used := 0
238 for _, w := range words {
239 n := cells(w)
240 switch {
241 case used == 0:
242 case w == "|" || used+1+n > width:
243 b.WriteString("\n" + indent)
244 used = 0
245 default:
246 b.WriteByte(' ')
247 used++
248 }
249 b.WriteString(w)
250 used += n
251 }
252 return b.String()
253}
254
255// diff is a unified diff, a git stat block, or range-diff output as a
256// terminal shows it: made safe, and with colour file headers bold, hunk
257// headers cyan, added lines green and removed lines red. Plain output
258// and a terminal without colour get it unpainted.
259func (t Term) diff(patch string) string {
260 if t.Cols == 0 {
261 return patch
262 }
263 patch = termSafe(patch)
264 if !t.Color {
265 return patch
266 }
267 lines := strings.Split(patch, "\n")
268 inDiff := false
269 for i, l := range lines {
270 switch {
271 case strings.HasPrefix(l, "diff --git "):
272 inDiff = true
273 lines[i] = t.paint(sgrBold, l)
274 case !inDiff:
275 lines[i] = t.paintPreamble(l)
276 case strings.HasPrefix(l, "--- "), strings.HasPrefix(l, "+++ "),
277 strings.HasPrefix(l, "index "), strings.HasPrefix(l, "new file mode"),
278 strings.HasPrefix(l, "deleted file mode"), strings.HasPrefix(l, "old mode"),
279 strings.HasPrefix(l, "new mode"), strings.HasPrefix(l, "similarity index"),
280 strings.HasPrefix(l, "rename from"), strings.HasPrefix(l, "rename to"),
281 strings.HasPrefix(l, "Binary files"):
282 lines[i] = t.paint(sgrBold, l)
283 default:
284 lines[i] = t.paintDiffLine(l)
285 }
286 }
287 return strings.Join(lines, "\n")
288}
289
290// paintPreamble colours what comes before the first file in a diff:
291// a stat line's +/- bar, or a range-diff line (a commit pair, bold, or
292// an indented line of the diff between the two patches).
293func (t Term) paintPreamble(l string) string {
294 if rest, ok := strings.CutPrefix(l, " "); ok {
295 return " " + t.paintDiffLine(rest)
296 }
297 if path, bar, ok := strings.Cut(l, " | "); ok && strings.HasPrefix(l, " ") {
298 n := strings.TrimRight(bar, "+-")
299 plus := strings.Count(bar[len(n):], "+")
300 return path + " | " + n + t.paint(sgrGreen, strings.Repeat("+", plus)) +
301 t.paint(sgrRed, bar[len(n)+plus:])
302 }
303 if rangePair(l) {
304 return t.paint(sgrBold, l)
305 }
306 return l
307}
308
309// paintDiffLine colours one line of a hunk by its first byte.
310func (t Term) paintDiffLine(l string) string {
311 switch {
312 case strings.HasPrefix(l, "@@"):
313 if end := strings.Index(l[2:], "@@"); end >= 0 {
314 return t.paint(sgrCyan, l[:end+4]) + l[end+4:]
315 }
316 return t.paint(sgrCyan, l)
317 case strings.HasPrefix(l, "+"):
318 return t.paint(sgrGreen, l)
319 case strings.HasPrefix(l, "-"):
320 return t.paint(sgrRed, l)
321 }
322 return l
323}
324
325// rangePair reports whether l is a range-diff commit pair line:
326// "1: abc1234 = 1: def5678 subject", either side possibly "-: -------".
327func rangePair(l string) bool {
328 f := strings.Fields(l)
329 return len(f) >= 5 && strings.HasSuffix(f[0], ":") && strings.HasSuffix(f[3], ":") &&
330 strings.ContainsAny(f[2], "=!<>") && len(f[2]) == 1
331}
332
333// toolSGR matches the colour sequences build tools print.
334var toolSGR = regexp.MustCompile("\x1b\\[[0-9;]*m")
335
336// buildLog is a build log as a terminal shows it: the tools' own colour
337// dropped and the rest made safe, since a repository's build writes
338// it; step lines ("$ make test") bold, the failed step's in red.
339func (t Term) buildLog(log, failed string) string {
340 log = termSafe(toolSGR.ReplaceAllString(log, ""))
341 if !t.Color {
342 return log
343 }
344 lines := strings.Split(log, "\n")
345 for i, l := range lines {
346 step, ok := strings.CutPrefix(l, "$ ")
347 switch {
348 case !ok:
349 case failed != "" && step == failed:
350 lines[i] = t.paint(sgrBold+sgrRed, l)
351 default:
352 lines[i] = t.paint(sgrBold, l)
353 }
354 }
355 return strings.Join(lines, "\n")
356}
357
358// cells is the width of s in terminal cells: SGR sequences and
359// combining marks take none, East Asian wide and fullwidth runes two.
360func cells(s string) int {
361 n := 0
362 for i := 0; i < len(s); {
363 if s[i] == 0x1b {
364 j := strings.IndexByte(s[i:], 'm')
365 if j < 0 {
366 break
367 }
368 i += j + 1
369 continue
370 }
371 r, size := utf8.DecodeRuneInString(s[i:])
372 i += size
373 n += runeCells(r)
374 }
375 return n
376}
377
378func runeCells(r rune) int {
379 if unicode.In(r, unicode.Mn, unicode.Me) || r == '' {
380 return 0
381 }
382 switch width.LookupRune(r).Kind() {
383 case width.EastAsianWide, width.EastAsianFullwidth:
384 return 2
385 }
386 return 1
387}
388
389// clip cuts s to at most w cells, ending in "…" when anything was cut.
390// s must carry no SGR sequences: colour goes on after clipping.
391func clip(s string, w int) string {
392 if cells(s) <= w {
393 return s
394 }
395 var b strings.Builder
396 used := 0
397 for _, r := range s {
398 rc := runeCells(r)
399 if used+rc > w-1 {
400 break
401 }
402 b.WriteRune(r)
403 used += rc
404 }
405 return b.String() + "…"
406}
407
408// pad right-pads s with spaces to w cells.
409func pad(s string, w int) string {
410 return s + strings.Repeat(" ", max(0, w-cells(s)))
411}
412
413// termNow is the clock ages are measured against; tests pin it.
414var termNow = time.Now
415
416// parseStamp reads a stored timestamp: RFC3339 as the store writes it,
417// or SQLite's datetime() form.
418func parseStamp(s string) (time.Time, bool) {
419 for _, layout := range []string{time.RFC3339Nano, "2006-01-02 15:04:05"} {
420 if t, err := time.Parse(layout, s); err == nil {
421 return t.UTC(), true
422 }
423 }
424 return time.Time{}, false
425}
426
427// stamp is a stored timestamp in plain output: RFC3339 to the second.
428func stamp(s string) string {
429 t, ok := parseStamp(s)
430 if !ok {
431 return s
432 }
433 return t.Format("2006-01-02T15:04:05Z")
434}
435
436// relAge is a stored timestamp as a table shows it at a terminal: "2h
437// ago" in the past, "in 2h" in the future, a date beyond eight weeks
438// either way.
439func relAge(s string, now time.Time) string {
440 t, ok := parseStamp(s)
441 if !ok {
442 return s
443 }
444 d := now.Sub(t)
445 future := d < 0
446 if future {
447 d = -d
448 }
449 var n string
450 switch {
451 case d < time.Minute:
452 return "just now"
453 case d < time.Hour:
454 n = fmt.Sprintf("%dm", int(d/time.Minute))
455 case d < 24*time.Hour:
456 n = fmt.Sprintf("%dh", int(d/time.Hour))
457 case d < 14*24*time.Hour:
458 n = fmt.Sprintf("%dd", int(d/(24*time.Hour)))
459 case d < 56*24*time.Hour:
460 n = fmt.Sprintf("%dw", int(d/(7*24*time.Hour)))
461 default:
462 return t.Format("2006-01-02")
463 }
464 if future {
465 return "in " + n
466 }
467 return n + " ago"
468}
469
470// size is a byte count: the number in plain output, KiB and up at a
471// terminal.
472func (t Term) size(n int64) string {
473 if t.Cols == 0 {
474 return strconv.FormatInt(n, 10)
475 }
476 return humanBytes(n)
477}
478
479// dur is a number of seconds: "<n>s" in plain output, hours, minutes
480// and seconds at a terminal.
481func (t Term) dur(secs int64) string {
482 if t.Cols == 0 {
483 return fmt.Sprintf("%ds", secs)
484 }
485 return (time.Duration(secs) * time.Second).String()
486}