internal/control/term.go

9df917e73a67d15adecc3f45976690f6fcd4e47a
gitbay/internal/control/term.go history · blame · raw

202 lines · 4826 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	sgrRed     = "\x1b[31m"
 44	sgrGreen   = "\x1b[32m"
 45	sgrMagenta = "\x1b[35m"
 46)
 47
 48// termSafe replaces the bytes a terminal would act on — ESC, the C0
 49// controls but tab and newline, DEL, and the C1 controls — with U+FFFD,
 50// so user text cannot move the cursor, set the clipboard (OSC 52) or
 51// clear the screen. Carriage return is dropped rather than replaced:
 52// web forms store CRLF line endings, and a lone CR would let text
 53// overwrite its own line. Terminal output only: plain output is unchanged.
 54func termSafe(s string) string {
 55	unsafe := func(r rune) bool {
 56		return (r < 0x20 && r != '\t' && r != '\n') || (r >= 0x7f && r <= 0x9f)
 57	}
 58	if strings.IndexFunc(s, unsafe) < 0 {
 59		return s
 60	}
 61	return strings.Map(func(r rune) rune {
 62		switch {
 63		case r == '\r':
 64			return -1
 65		case unsafe(r):
 66			return '\uFFFD'
 67		}
 68		return r
 69	}, s)
 70}
 71
 72// safe is termSafe at a terminal and s unchanged in plain output.
 73func (t Term) safe(s string) string {
 74	if t.Cols == 0 {
 75		return s
 76	}
 77	return termSafe(s)
 78}
 79
 80// paint wraps s in an SGR sequence when colour is on.
 81func (t Term) paint(sgr, s string) string {
 82	if !t.Color || sgr == "" || s == "" {
 83		return s
 84	}
 85	return sgr + s + sgrReset
 86}
 87
 88// stateColor maps a state word to the web's state tokens: --ok green,
 89// --done magenta, --bad red, --neutral dim.
 90func stateColor(s string) string {
 91	switch s {
 92	case "open", "success", "approved", "active":
 93		return sgrGreen
 94	case "merged":
 95		return sgrMagenta
 96	case "failed", "failure", "error", "changes requested":
 97		return sgrRed
 98	case "closed", "draft", "pending", "canceled", "cancelled", "archived", "disabled":
 99		return sgrDim
100	}
101	return ""
102}
103
104// cells is the width of s in terminal cells: SGR sequences and
105// combining marks take none, East Asian wide and fullwidth runes two.
106func cells(s string) int {
107	n := 0
108	for i := 0; i < len(s); {
109		if s[i] == 0x1b {
110			j := strings.IndexByte(s[i:], 'm')
111			if j < 0 {
112				break
113			}
114			i += j + 1
115			continue
116		}
117		r, size := utf8.DecodeRuneInString(s[i:])
118		i += size
119		n += runeCells(r)
120	}
121	return n
122}
123
124func runeCells(r rune) int {
125	if unicode.In(r, unicode.Mn, unicode.Me) || r == '‍' {
126		return 0
127	}
128	switch width.LookupRune(r).Kind() {
129	case width.EastAsianWide, width.EastAsianFullwidth:
130		return 2
131	}
132	return 1
133}
134
135// clip cuts s to at most w cells, ending in "…" when anything was cut.
136// s must carry no SGR sequences: colour goes on after clipping.
137func clip(s string, w int) string {
138	if cells(s) <= w {
139		return s
140	}
141	var b strings.Builder
142	used := 0
143	for _, r := range s {
144		rc := runeCells(r)
145		if used+rc > w-1 {
146			break
147		}
148		b.WriteRune(r)
149		used += rc
150	}
151	return b.String() + "…"
152}
153
154// pad right-pads s with spaces to w cells.
155func pad(s string, w int) string {
156	return s + strings.Repeat(" ", max(0, w-cells(s)))
157}
158
159// termNow is the clock ages are measured against; tests pin it.
160var termNow = time.Now
161
162// parseStamp reads a stored timestamp: RFC3339 as the store writes it,
163// or SQLite's datetime() form.
164func parseStamp(s string) (time.Time, bool) {
165	for _, layout := range []string{time.RFC3339Nano, "2006-01-02 15:04:05"} {
166		if t, err := time.Parse(layout, s); err == nil {
167			return t.UTC(), true
168		}
169	}
170	return time.Time{}, false
171}
172
173// stamp is a stored timestamp in plain output: RFC3339 to the second.
174func stamp(s string) string {
175	t, ok := parseStamp(s)
176	if !ok {
177		return s
178	}
179	return t.Format("2006-01-02T15:04:05Z")
180}
181
182// relAge is a stored timestamp as a table shows it at a terminal.
183func relAge(s string, now time.Time) string {
184	t, ok := parseStamp(s)
185	if !ok {
186		return s
187	}
188	d := max(now.Sub(t), 0)
189	switch {
190	case d < time.Minute:
191		return "just now"
192	case d < time.Hour:
193		return fmt.Sprintf("%dm ago", int(d/time.Minute))
194	case d < 24*time.Hour:
195		return fmt.Sprintf("%dh ago", int(d/time.Hour))
196	case d < 14*24*time.Hour:
197		return fmt.Sprintf("%dd ago", int(d/(24*time.Hour)))
198	case d < 56*24*time.Hour:
199		return fmt.Sprintf("%dw ago", int(d/(7*24*time.Hour)))
200	}
201	return t.Format("2006-01-02")
202}