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}