internal/control/cursor.go
169 lines · 4882 bytes
13 symbols in this file
1package control
2
3import (
4 "encoding/base64"
5 "errors"
6 "fmt"
7 "io"
8 "reflect"
9 "regexp"
10 "slices"
11 "strconv"
12 "strings"
13
14 "gitbay.org/gitbay/internal/protocol"
15)
16
17// bareWord matches arguments that need no quoting for the server-side
18// POSIX tokenizer.
19var bareWord = regexp.MustCompile(`^[A-Za-z0-9@%+=:,./_!-]+$`)
20
21// shellWord quotes one argument the way the gitbay CLI does, so a
22// printed command can be pasted.
23func shellWord(arg string) string {
24 if arg != "" && bareWord.MatchString(arg) {
25 return arg
26 }
27 return "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'"
28}
29
30// Cursor pagination. A cursor is opaque to clients: base64url of
31// "<kind>:<key>", where key is the sort key of the last row of the
32// previous page. The kind keeps a cursor minted by one command from
33// being fed to another.
34
35const maxPageLimit = 200
36
37func encodeCursor(kind, key string) string {
38 return base64.RawURLEncoding.EncodeToString([]byte(kind + ":" + key))
39}
40
41func decodeCursor(kind, cursor string) (string, error) {
42 raw, err := base64.RawURLEncoding.DecodeString(cursor)
43 if err != nil {
44 return "", errors.New("bad cursor")
45 }
46 k, key, ok := strings.Cut(string(raw), ":")
47 if !ok || k != kind || key == "" {
48 return "", errors.New("bad cursor")
49 }
50 return key, nil
51}
52
53// page carries parsed --limit/--cursor flags. active marks that either
54// flag was given: only then does the output switch to the paged shape.
55type page struct {
56 limit int
57 key string // decoded cursor key, "" means from the start
58 active bool
59}
60
61// queryLimit is what the store is asked for: one row beyond the page, so
62// the presence of a following page is known without a second query.
63func (p page) queryLimit() int {
64 if p.limit == 0 {
65 return 0
66 }
67 return p.limit + 1
68}
69
70// keyInt returns the cursor key as a number; parsePageFlags has already
71// validated it for numeric kinds.
72func (p page) keyInt() int64 {
73 n, _ := strconv.ParseInt(p.key, 10, 64)
74 return n
75}
76
77// parsePageFlags strips --limit and --cursor from args. kind names the
78// cursor namespace; numeric declares the sort key an integer.
79func parsePageFlags(c *Ctx, args []string, kind string, numeric bool) (rest []string, p page, code int) {
80 for i := 0; i < len(args); i++ {
81 switch args[i] {
82 case "--limit":
83 if i+1 >= len(args) {
84 return nil, p, c.fail(protocol.ExitUsage, "--limit requires a value")
85 }
86 n, err := strconv.Atoi(args[i+1])
87 if err != nil || n < 1 || n > maxPageLimit {
88 return nil, p, c.fail(protocol.ExitUsage, "--limit must be 1 to %d", maxPageLimit)
89 }
90 p.limit, p.active = n, true
91 i++
92 case "--cursor":
93 if i+1 >= len(args) {
94 return nil, p, c.fail(protocol.ExitUsage, "--cursor requires a value")
95 }
96 key, err := decodeCursor(kind, args[i+1])
97 if err == nil && numeric {
98 _, err = strconv.ParseInt(key, 10, 64)
99 }
100 if err != nil {
101 return nil, p, c.fail(protocol.ExitUsage, "bad cursor")
102 }
103 p.key, p.active = key, true
104 i++
105 default:
106 rest = append(rest, args[i])
107 }
108 }
109 return rest, p, -1
110}
111
112// trimPage cuts the probe row and derives the next cursor from the last
113// row kept.
114func trimPage[T any](p page, items []T, kind string, key func(T) string) ([]T, string) {
115 if p.limit == 0 || len(items) <= p.limit {
116 return items, ""
117 }
118 items = items[:p.limit]
119 return items, encodeCursor(kind, key(items[len(items)-1]))
120}
121
122// emitPage renders a list result. Without pagination flags the shape is
123// the bare array it has always been; with them the array moves under
124// "items" with the next cursor alongside.
125func (c *Ctx) emitPage(p page, items any, next string, plain func(w io.Writer)) int {
126 if !p.active {
127 return c.emit(items, plain)
128 }
129 if v := reflect.ValueOf(items); v.Kind() == reflect.Slice && v.IsNil() {
130 items = reflect.MakeSlice(v.Type(), 0, 0).Interface()
131 }
132 type out struct {
133 Items any `json:"items"`
134 Next string `json:"next,omitempty"`
135 }
136 return c.emit(out{items, next}, func(w io.Writer) {
137 plain(w)
138 if next == "" {
139 return
140 }
141 if c.Term.Cols == 0 {
142 fmt.Fprintf(w, "next\t%s\n", next)
143 return
144 }
145 fmt.Fprintln(c.Stderr, c.Term.paint(sgrBold, "Next page")+" "+c.Term.paint(sgrBlue, c.cmdline(c.nextArgv(next))))
146 })
147}
148
149// emitPageView is emitPage for a list with a terminal screen.
150func (c *Ctx) emitPageView(p page, items any, next string, plain func(w io.Writer), build func() screen) int {
151 if c.Term.Cols == 0 || c.JSON {
152 return c.emitPage(p, items, next, plain)
153 }
154 return c.emitPage(p, items, next, func(w io.Writer) { c.render(w, build()) })
155}
156
157// nextArgv is the command that fetches the page after this one: this
158// command's argv with its cursor replaced.
159func (c *Ctx) nextArgv(next string) []string {
160 argv := slices.Clone(c.Cmd.Path)
161 for i := 0; i < len(c.Argv); i++ {
162 if c.Argv[i] == "--cursor" {
163 i++
164 continue
165 }
166 argv = append(argv, c.Argv[i])
167 }
168 return append(argv, "--cursor", next)
169}