internal/control/term.go

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

171 lines · 3937 bytes

  1package control
  2
  3import (
  4	"fmt"
  5	"strconv"
  6	"strings"
  7	"time"
  8	"unicode"
  9	"unicode/utf8"
 10
 11	"golang.org/x/text/width"
 12)
 13
 14// Term is what the client said about its terminal (GITBAY_TERM). The
 15// zero value is plain output: tab-separated rows, no header, no colour,
 16// which is what stock ssh, the API and the web get.
 17type Term struct {
 18	Cols  int
 19	Color bool
 20}
 21
 22// ParseTerm reads "<cols>[,color]". Anything else, or a width outside
 23// 40 to 1000, is plain output.
 24func ParseTerm(v string) Term {
 25	cols, opt, hasOpt := strings.Cut(v, ",")
 26	n, err := strconv.Atoi(cols)
 27	if err != nil || n < 40 || n > 1000 {
 28		return Term{}
 29	}
 30	switch {
 31	case !hasOpt:
 32		return Term{Cols: n}
 33	case opt == "color":
 34		return Term{Cols: n, Color: true}
 35	}
 36	return Term{}
 37}
 38
 39const (
 40	sgrReset     = "\x1b[0m"
 41	sgrBold      = "\x1b[1m"
 42	sgrDim       = "\x1b[2m"
 43	sgrUnderline = "\x1b[4m"
 44	sgrRed       = "\x1b[31m"
 45	sgrGreen     = "\x1b[32m"
 46	sgrMagenta   = "\x1b[35m"
 47)
 48
 49// paint wraps s in an SGR sequence when colour is on.
 50func (t Term) paint(sgr, s string) string {
 51	if !t.Color || sgr == "" || s == "" {
 52		return s
 53	}
 54	return sgr + s + sgrReset
 55}
 56
 57// stateColor maps a state word to the web's state tokens: --ok green,
 58// --done magenta, --bad red, --neutral dim.
 59func stateColor(s string) string {
 60	switch s {
 61	case "open", "success", "approved", "active":
 62		return sgrGreen
 63	case "merged":
 64		return sgrMagenta
 65	case "failed", "failure", "error", "changes requested":
 66		return sgrRed
 67	case "closed", "draft", "pending", "canceled", "cancelled", "archived", "disabled":
 68		return sgrDim
 69	}
 70	return ""
 71}
 72
 73// cells is the width of s in terminal cells: SGR sequences and
 74// combining marks take none, East Asian wide and fullwidth runes two.
 75func cells(s string) int {
 76	n := 0
 77	for i := 0; i < len(s); {
 78		if s[i] == 0x1b {
 79			j := strings.IndexByte(s[i:], 'm')
 80			if j < 0 {
 81				break
 82			}
 83			i += j + 1
 84			continue
 85		}
 86		r, size := utf8.DecodeRuneInString(s[i:])
 87		i += size
 88		n += runeCells(r)
 89	}
 90	return n
 91}
 92
 93func runeCells(r rune) int {
 94	if unicode.In(r, unicode.Mn, unicode.Me) || r == '‍' {
 95		return 0
 96	}
 97	switch width.LookupRune(r).Kind() {
 98	case width.EastAsianWide, width.EastAsianFullwidth:
 99		return 2
100	}
101	return 1
102}
103
104// clip cuts s to at most w cells, ending in "…" when anything was cut.
105// s must carry no SGR sequences: colour goes on after clipping.
106func clip(s string, w int) string {
107	if cells(s) <= w {
108		return s
109	}
110	var b strings.Builder
111	used := 0
112	for _, r := range s {
113		rc := runeCells(r)
114		if used+rc > w-1 {
115			break
116		}
117		b.WriteRune(r)
118		used += rc
119	}
120	return b.String() + "…"
121}
122
123// pad right-pads s with spaces to w cells.
124func pad(s string, w int) string {
125	return s + strings.Repeat(" ", max(0, w-cells(s)))
126}
127
128// termNow is the clock ages are measured against; tests pin it.
129var termNow = time.Now
130
131// parseStamp reads a stored timestamp: RFC3339 as the store writes it,
132// or SQLite's datetime() form.
133func parseStamp(s string) (time.Time, bool) {
134	for _, layout := range []string{time.RFC3339Nano, "2006-01-02 15:04:05"} {
135		if t, err := time.Parse(layout, s); err == nil {
136			return t.UTC(), true
137		}
138	}
139	return time.Time{}, false
140}
141
142// stamp is a stored timestamp in plain output: RFC3339 to the second.
143func stamp(s string) string {
144	t, ok := parseStamp(s)
145	if !ok {
146		return s
147	}
148	return t.Format("2006-01-02T15:04:05Z")
149}
150
151// relAge is a stored timestamp as a table shows it at a terminal.
152func relAge(s string, now time.Time) string {
153	t, ok := parseStamp(s)
154	if !ok {
155		return s
156	}
157	d := max(now.Sub(t), 0)
158	switch {
159	case d < time.Minute:
160		return "just now"
161	case d < time.Hour:
162		return fmt.Sprintf("%dm ago", int(d/time.Minute))
163	case d < 24*time.Hour:
164		return fmt.Sprintf("%dh ago", int(d/time.Hour))
165	case d < 14*24*time.Hour:
166		return fmt.Sprintf("%dd ago", int(d/(24*time.Hour)))
167	case d < 56*24*time.Hour:
168		return fmt.Sprintf("%dw ago", int(d/(7*24*time.Hour)))
169	}
170	return t.Format("2006-01-02")
171}