internal/control/term.go
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}