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