Commit 583b480f3a

583b480f3a46a5c596cf64b85210e33b09a9e875

parent: b19c86712e

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-02 03:16 UTC

control: emitView and emitPageView; next page as a legend line

Ref #317

Layout: unified · split

internal/control/control.go +10
@@ -360,6 +360,16 @@ type emptyReader struct{}
360 360
361func (emptyReader) Read([]byte) (int, error) { return 0, io.EOF } 361func (emptyReader) Read([]byte) (int, error) { return 0, io.EOF }
362 362
363// emitView is emit for a command with a terminal screen: --json and
364// piped output are emit's, and at a terminal build's screen is drawn
365// in place of plain.
366func (c *Ctx) emitView(data any, plain func(w io.Writer), build func() screen) int {
367 if c.Term.Cols == 0 || c.JSON {
368 return c.emit(data, plain)
369 }
370 return c.emit(data, func(w io.Writer) { c.render(w, build()) })
371}
372
363// emit writes data as the command result: a JSON envelope under --json, 373// emit writes data as the command result: a JSON envelope under --json,
364// otherwise via the plain formatter. 374// otherwise via the plain formatter.
365func (c *Ctx) emit(data any, plain func(w io.Writer)) int { 375func (c *Ctx) emit(data any, plain func(w io.Writer)) int {
internal/control/cursor.go +24 −14
@@ -7,6 +7,7 @@ import (
7 "io" 7 "io"
8 "reflect" 8 "reflect"
9 "regexp" 9 "regexp"
10 "slices"
10 "strconv" 11 "strconv"
11 "strings" 12 "strings"
12 13
@@ -141,19 +142,28 @@ func (c *Ctx) emitPage(p page, items any, next string, plain func(w io.Writer))
141 fmt.Fprintf(w, "next\t%s\n", next) 142 fmt.Fprintf(w, "next\t%s\n", next)
142 return 143 return
143 } 144 }
144 var again []string 145 fmt.Fprintln(c.Stderr, c.Term.paint(sgrBold, "Next page")+" "+c.Term.paint(sgrBlue, c.cmdline(c.nextArgv(next))))
145 for i := 0; i < len(c.Argv); i++ {
146 if c.Argv[i] == "--cursor" {
147 i++
148 continue
149 }
150 again = append(again, c.Argv[i])
151 }
152 cmd := []string{"gitbay", joinPath(c.Cmd.Path)}
153 for _, a := range again {
154 cmd = append(cmd, shellWord(a))
155 }
156 cmd = append(cmd, "--cursor", next)
157 fmt.Fprintf(c.Stderr, "more: %s\n", strings.Join(cmd, " "))
158 }) 146 })
159} 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}
internal/control/cursor_test.go +10 −6
@@ -81,12 +81,16 @@ func TestEmitPageHintsTheNextPageAtATerminal(t *testing.T) {
81 t.Errorf("cursor row on stdout at a terminal") 81 t.Errorf("cursor row on stdout at a terminal")
82 } 82 }
83 stderr := errOut.String() 83 stderr := errOut.String()
84 want := "more: gitbay build list " + repo.Path() + " --limit 2 --cursor " 84 cmd, ok := strings.CutPrefix(stderr, "Next page ")
85 if !strings.Contains(stderr, want) { 85 if !ok {
86 t.Errorf("stderr = %q, want %q…", stderr, want) 86 t.Fatalf("stderr = %q, want a Next page line", stderr)
87 } 87 }
88 // Ensure no double space in the output. 88 want := "gitbay build list " + repo.Path() + " --limit 2 --cursor "
89 if strings.Contains(stderr, " ") { 89 if !strings.HasPrefix(cmd, want) {
90 t.Errorf("stderr contains double space: %q", stderr) 90 t.Errorf("command = %q, want %q…", cmd, want)
91 }
92 // No empty word in the command.
93 if strings.Contains(cmd, " ") {
94 t.Errorf("command contains double space: %q", cmd)
91 } 95 }
92} 96}
internal/control/screen_test.go +29
@@ -1,7 +1,9 @@
1package control 1package control
2 2
3import ( 3import (
4 "bytes"
4 "fmt" 5 "fmt"
6 "io"
5 "strings" 7 "strings"
6 "testing" 8 "testing"
7) 9)
@@ -177,3 +179,30 @@ func TestLegendColour(t *testing.T) {
177 t.Error("colour legend differs beyond SGR") 179 t.Error("colour legend differs beyond SGR")
178 } 180 }
179} 181}
182
183func TestEmitViewRoutes(t *testing.T) {
184 built := false
185 build := func() screen { built = true; return screen{fields: []field{{"Repo", []cell{cText("a/b")}}}} }
186 plain := func(w io.Writer) { io.WriteString(w, "plain\n") }
187
188 var out bytes.Buffer
189 c := &Ctx{Stdout: &out, Stderr: io.Discard}
190 c.emitView(map[string]string{"k": "v"}, plain, build)
191 if out.String() != "plain\n" || built {
192 t.Errorf("piped: %q built=%v", out.String(), built)
193 }
194
195 out.Reset()
196 c = &Ctx{Stdout: &out, Stderr: io.Discard, JSON: true, Term: Term{Cols: 80}}
197 c.emitView(map[string]string{"k": "v"}, plain, build)
198 if !strings.Contains(out.String(), `"k":"v"`) || built {
199 t.Errorf("json: %q built=%v", out.String(), built)
200 }
201
202 out.Reset()
203 c = &Ctx{Stdout: &out, Stderr: io.Discard, Term: Term{Cols: 80}}
204 c.emitView(map[string]string{"k": "v"}, plain, build)
205 if out.String() != "Repo: a/b\n" || !built {
206 t.Errorf("terminal: %q built=%v", out.String(), built)
207 }
208}