internal/control/cursor.go

169 lines · 4882 bytes

  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}