internal/control/term.go

e6cd75b5f28bacf51620bb531320c30fd4e66bfd
gitbay/internal/control/term.go history · blame · raw

486 lines · 13325 bytes

38 symbols in this file
  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}