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