| @@ -0,0 +1,171 @@ |
| 1 | package control |
| 2 | |
| 3 | import ( |
| 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. |
| 17 | type 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. |
| 24 | func 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 | |
| 39 | const ( |
| 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. |
| 50 | func (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. |
| 59 | func 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. |
| 75 | func 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 | |
| 93 | func 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. |
| 106 | func 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. |
| 124 | func 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. |
| 129 | var termNow = time.Now |
| 130 | |
| 131 | // parseStamp reads a stored timestamp: RFC3339 as the store writes it, |
| 132 | // or SQLite's datetime() form. |
| 133 | func 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. |
| 143 | func 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. |
| 152 | func 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 | } |