Commit e410f8a408

e410f8a408475f61fb21dd855a5234f507252cd6

parent: 683d5a1794

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-24 00:53 UTC

control: Term, cell width, clipping and timestamp formats

Ref #254
go.mod +1 −1
@@ -16,6 +16,7 @@ require (
1616 golang.org/x/image v0.46.0
1717 golang.org/x/net v0.59.0
1818 golang.org/x/term v0.46.0
19 golang.org/x/text v0.42.0
1920 modernc.org/sqlite v1.59.0
2021)
2122
@@ -34,7 +35,6 @@ require (
3435 github.com/russross/blackfriday/v2 v2.1.0 // indirect
3536 github.com/spf13/pflag v1.0.9 // indirect
3637 golang.org/x/sys v0.48.0 // indirect
37 golang.org/x/text v0.42.0 // indirect
3838 modernc.org/libc v1.75.7 // indirect
3939 modernc.org/mathutil v1.7.1 // indirect
4040 modernc.org/memory v1.12.1 // indirect
internal/control/term.go added +171
@@ -0,0 +1,171 @@
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}
internal/control/term_test.go added +81
@@ -0,0 +1,81 @@
1package control
2
3import (
4 "testing"
5 "time"
6)
7
8func TestParseTerm(t *testing.T) {
9 cases := map[string]Term{
10 "120": {Cols: 120},
11 "120,color": {Cols: 120, Color: true},
12 "40": {Cols: 40},
13 "39": {},
14 "": {},
15 "abc": {},
16 "80,blink": {},
17 "80,": {},
18 "5000": {},
19 }
20 for in, want := range cases {
21 if got := ParseTerm(in); got != want {
22 t.Errorf("ParseTerm(%q) = %+v, want %+v", in, got, want)
23 }
24 }
25}
26
27func TestCells(t *testing.T) {
28 cases := map[string]int{
29 "abc": 3,
30 "日本": 4,
31 "é": 1,
32 "é": 1,
33 "\x1b[32mopen\x1b[0m": 4,
34 "": 0,
35 }
36 for in, want := range cases {
37 if got := cells(in); got != want {
38 t.Errorf("cells(%q) = %d, want %d", in, got, want)
39 }
40 }
41}
42
43func TestClip(t *testing.T) {
44 if got := clip("Dependency updates available", 14); got != "Dependency up…" {
45 t.Errorf("clip = %q", got)
46 }
47 if got := clip("short", 14); got != "short" {
48 t.Errorf("clip = %q", got)
49 }
50 if got := clip("日本語のタイトル", 7); got != "日本語…" {
51 t.Errorf("clip wide = %q", got)
52 }
53}
54
55func TestStampAndRelAge(t *testing.T) {
56 if got := stamp("2026-09-23T23:26:00.570Z"); got != "2026-09-23T23:26:00Z" {
57 t.Errorf("stamp = %q", got)
58 }
59 if got := stamp("2026-09-23 23:26:00"); got != "2026-09-23T23:26:00Z" {
60 t.Errorf("stamp sqlite = %q", got)
61 }
62 if got := stamp("garbage"); got != "garbage" {
63 t.Errorf("stamp garbage = %q", got)
64 }
65 now := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC)
66 cases := map[string]string{
67 "2026-09-23T11:59:30Z": "just now",
68 "2026-09-23T11:55:00Z": "5m ago",
69 "2026-09-23T10:00:00Z": "2h ago",
70 "2026-09-20T12:00:00Z": "3d ago",
71 "2026-09-02T12:00:00Z": "3w ago",
72 "2026-06-01T12:00:00Z": "2026-06-01",
73 "2026-09-24T12:00:00Z": "just now",
74 "not a time": "not a time",
75 }
76 for in, want := range cases {
77 if got := relAge(in, now); got != want {
78 t.Errorf("relAge(%q) = %q, want %q", in, got, want)
79 }
80 }
81}