control: remaining screens; view is piped-only (CLI views stage 3c) !558

merged merged by cmc on 2026-10-02 05:05 UTC · krz/gitbay:cli-views-3c into main

51 files changed, +843 −268

Layout: unified · split

.gitbay/wiki/Users.org +18 −35
@@ -1024,41 +1024,24 @@ then prints:
10241024 =krz/gitbay=, SHAs) dim. A marker piped output keeps as its own
10251025 trailing cell (=[archived]=, =primary=) joins the state at a terminal:
10261026 =public, archived=;
1027- =mr list=, =issue list= and =build list= as one-section screens:
1028 each row led by =●= when it waits on you (your review requested, an
1029 issue assigned to you), otherwise by its checks' or state's glyph;
1030 =mr list= with review state, branch and age, =issue list= with
1031 labels, assignees, comments and age. A =dashboard= screen opens with
1032 what waits on you, then open merge requests, failed builds (a job's
1033 latest build only), five lines of activity with a count of builds
1034 that passed, and pinned repositories; for admins a =Problems= line
1035 counts background failures, and =admin stats= has the detail. A
1036 column empty on every row is left out. These columns are
1037 terminal-only; piped rows keep their own;
1038- diffs coloured (file headers bold, hunks cyan, added green, removed
1039 red), and =build log= with its step lines bold, the failed one red;
1040 both made safe like any user text, the log's own colour codes dropped;
1041- ages as =2h ago= and =in 5h=, sizes as =3.0 MiB=, durations as
1042 =53m43s=, where piped output keeps RFC3339, bytes and seconds;
1043- =mr show=, =issue show=, =repo show= and =build show= drawn as
1044 screens: a header block of =Label:= lines (labels dim), the
1045 description, sections titled =Title (n)= in bold blue (=Commits=,
1046 =Files=, =Discussion=, =Events=, =Steps=, open merge requests and
1047 issues), and a legend under a rule of the commands that apply now,
1048 grouped (=Unblock=, =Review=, =Merge=, =Read=, ...) and chosen from
1049 the item's state and your access. Rows have no header and no indent:
1050 a dim reference, a state glyph, the text, then dim facts joined by
1051 =·=. The glyphs are =✓= passed, =✗= failed or blocked, =◐= still
1052 going, =●= waiting on you, =○= closed or draft. A section cut short
1053 ends with =+n more= and the command for the rest. Below 80 columns the
1054 legend's groups stack; a suggested command is never cut, so it may run
1055 past the width;
1056- other =show= views with a title line, aligned fields, the body rendered
1057 from markdown or org, one line per event, and comments under a rule;
1058 a sub-table (labels, revisions, and the like) carries a section label
1059 line in both terminal and piped output; timestamps as
1060 =2026-09-23 23:26 UTC=. =wiki show= piped prints the page source
1061 verbatim; the rendered page is terminal-only;
1027- every list and show drawn as a screen: a header block of =Label:=
1028 lines (labels dim), the description or notes, sections titled =Title
1029 (n)= in bold blue, and a legend under a rule of the commands that
1030 apply now, grouped and chosen from the item's state and your access.
1031 Rows have no header (unless a column is a number or a size) and no
1032 indent: a dim reference, a state glyph, the text, then dim facts
1033 joined by =·=. The glyphs are =✓= passed, =✗= failed or blocked, =◐=
1034 still going, =●= waiting on you (your review requested, an issue
1035 assigned to you, an unread notification, a pending account, an
1036 overdue milestone, the session's own key), =○= closed or draft. A
1037 section cut short ends with =+n more= and the command for the rest.
1038 Below 80 columns the legend's groups stack; a suggested command is
1039 never cut, so it may run past the width. =dashboard= opens with what
1040 waits on you; for admins a =Problems= line counts background
1041 failures, and =admin stats= has the detail. =repo commit= prints its
1042 screen, then the diff. Secrets never appear on a screen, and a
1043 webhook URL shows its scheme and host only. =wiki show= piped prints
1044 the page source verbatim;
10621045- errors on stderr after a red =error:=, a mistyped flag with the flag
10631046 it is closest to (=did you mean --state?=), and a usage line wrapped
10641047 to the width between its bracketed groups, one alternative per line;
CHANGELOG.org +7
@@ -39,6 +39,13 @@ anything beyond "replace the binary and restart" is needed.
3939 milestone lists (repo and org), =org team list=/=show=. The session's
4040 own key leads with =●=; =pgp list= shows user IDs rather than their
4141 JSON; a webhook URL on screen is its scheme and host only.
42- The remaining commands at a terminal are screens (#319): saved
43 queries (and =issue list=/=mr list= with =--query=/=--q=), =search=
44 (a section per kind), =feed=, =mr revisions=, =issue templates=,
45 =build jobs=, =status list=, releases, snippets, wiki, =explore=,
46 =audit=, and the admin commands (=admin stats= lists the 20 largest
47 repositories). Every list and show is now a screen; the piped layout
48 is the only other one.
4249
4350* v1.41.0 — 2026-10-01
4451
internal/control/admin.go +142 −5
@@ -159,7 +159,7 @@ func runAdminUserList(c *Ctx, args []string) int {
159159 for _, u := range users {
160160 ds = append(ds, adminUserRow(u))
161161 }
162 return c.emitPage(p, ds, next, func(w io.Writer) {
162 return c.emitPageView(p, ds, next, func(w io.Writer) {
163163 tb := c.table(w, "USERNAME", "STATE", "ADMIN", "CREATED", "LAST SEEN")
164164 for _, d := range ds {
165165 mark := ""
@@ -169,6 +169,25 @@ func runAdminUserList(c *Ctx, args []string) int {
169169 tb.row(cRef(d.Username), cState(d.State), cText(mark), cAge(d.CreatedAt), cAge(d.LastSeen))
170170 }
171171 tb.flush()
172 }, func() screen {
173 rows := make([]row, len(ds))
174 for i, d := range ds {
175 lead := cGlyph("")
176 if d.State == "pending" {
177 lead = cYou()
178 }
179 admin, seen := "", ""
180 if d.Admin {
181 admin = "admin"
182 }
183 if d.LastSeen != "" {
184 seen = "seen " + relAge(d.LastSeen, termNow())
185 }
186 rows[i] = rowOf(cRef(d.Username), lead, cState(d.State), cMeta(admin, seen))
187 }
188 return listScreen("Accounts", rows,
189 action{"Filter", []string{"admin", "user", "list", "--state", "pending"}},
190 )
172191 })
173192}
174193
@@ -280,7 +299,7 @@ func runAdminUserShow(c *Ctx, args []string) int {
280299 return c.fail(protocol.ExitFailure, "%v", err)
281300 }
282301
283 return c.emit(d, func(w io.Writer) {
302 return c.emitView(d, func(w io.Writer) {
284303 admin := ""
285304 if d.Admin {
286305 admin = "yes"
@@ -346,6 +365,66 @@ func runAdminUserShow(c *Ctx, args []string) int {
346365 }
347366 tt.flush()
348367 }
368 }, func() screen {
369 s := screen{}
370 user := []cell{cRef(d.Username), cState(d.State)}
371 if d.Admin {
372 user = append(user, cMeta("admin"))
373 }
374 seen := "never seen"
375 if d.LastSeen != "" {
376 seen = "seen " + relAge(d.LastSeen, termNow())
377 }
378 s.fields = []field{
379 {"User", user},
380 {"Seen", []cell{cText(seen), cMeta("created " + relAge(d.CreatedAt, termNow()))}},
381 {"Repos", []cell{cText(strconv.FormatInt(d.Repos, 10))}},
382 {"Sessions", []cell{cText(strconv.FormatInt(d.WebSessions, 10))}},
383 }
384 keys := section{title: "Keys", n: len(d.Keys)}
385 for _, k := range d.Keys {
386 keys.rows = append(keys.rows, rowOf(cFlexRef(k.Fingerprint), cState(k.Scope), cMeta(k.Algo, "used "+relAge(k.LastUsedAt, termNow()))))
387 }
388 emails := section{title: "Emails", n: len(d.Emails)}
389 for _, e := range d.Emails {
390 state, primary := "unverified", ""
391 if e.Verified {
392 state = "verified"
393 }
394 if e.Primary {
395 primary = "primary"
396 }
397 emails.rows = append(emails.rows, rowOf(cRef(e.Address), cState(state), cMeta(primary, e.VerifiedBy)))
398 }
399 pgp := section{title: "PGP keys", n: len(d.PGPKeys)}
400 for _, k := range d.PGPKeys {
401 pgp.rows = append(pgp.rows, rowOf(cFlexRef(k.Fingerprint)))
402 }
403 orgs := section{title: "Orgs", n: len(d.Orgs)}
404 for _, o := range d.Orgs {
405 orgs.rows = append(orgs.rows, rowOf(cLink(o.Org, c.siteURL(o.Org)), cState(o.Role)))
406 }
407 tokens := section{title: "API tokens", n: len(d.APITokens)}
408 for _, tk := range d.APITokens {
409 used := ""
410 if tk.LastUsedAt != nil {
411 used = "used " + relAge(tk.LastUsedAt.UTC().Format(time.RFC3339Nano), termNow())
412 }
413 tokens.rows = append(tokens.rows, rowOf(cRef(tk.Name), cState(tk.Scope), cMeta(used)))
414 }
415 s.sections = []section{keys, emails, pgp, orgs, tokens}
416 role, state := "promote", "disable"
417 if d.Admin {
418 role = "demote"
419 }
420 if d.State == "disabled" {
421 state = "enable"
422 }
423 s.actions = []action{
424 {"Manage", []string{"admin", "user", role, d.Username}},
425 {"Manage", []string{"admin", "user", state, d.Username}},
426 }
427 return s
349428 })
350429}
351430
@@ -439,7 +518,7 @@ func runAdminRepoList(c *Ctx, args []string) int {
439518 size := gitutil.DirSize(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
440519 ds = append(ds, out{r.Path, r.Visibility, r.Archived, r.CreatedAt, r.LastPush, size})
441520 }
442 return c.emitPage(p, ds, next, func(w io.Writer) {
521 return c.emitPageView(p, ds, next, func(w io.Writer) {
443522 tb := c.table(w, "PATH", "VISIBILITY", "BYTES", "CREATED", "LAST PUSH")
444523 for _, d := range ds {
445524 cells := []cell{cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility), cSize(d.Bytes), cAge(d.CreatedAt), cAge(d.LastPush)}
@@ -449,6 +528,22 @@ func runAdminRepoList(c *Ctx, args []string) int {
449528 tb.row(cells...)
450529 }
451530 tb.flush()
531 }, func() screen {
532 rows := make([]row, len(ds))
533 for i, d := range ds {
534 state := d.Visibility
535 if d.Archived {
536 state += ", archived"
537 }
538 pushed := ""
539 if d.LastPush != "" {
540 pushed = "pushed " + relAge(d.LastPush, termNow())
541 }
542 rows[i] = rowOf(cLink(d.Path, c.siteURL(d.Path)), cState(state), cSize(d.Bytes), cMeta(pushed))
543 }
544 return listScreen("Repositories", rows,
545 action{"Filter", []string{"admin", "repo", "list", "--visibility", "private"}},
546 )
452547 })
453548}
454549
@@ -574,7 +669,7 @@ func runAdminRunners(c *Ctx, args []string) int {
574669 }
575670 }
576671 d := map[string]any{"queue": queue, "runners": runners}
577 return c.emit(d, func(w io.Writer) {
672 return c.emitView(d, func(w io.Writer) {
578673 v := c.view(w)
579674 v.fields(
580675 "pending", fmt.Sprintf("%d", queue.Pending),
@@ -599,6 +694,32 @@ func runAdminRunners(c *Ctx, args []string) int {
599694 tb.row(cText(r.Username), cText(r.Fingerprint), cAge(r.LastSeen), cFlex(scope), cText(held))
600695 }
601696 tb.flush()
697 }, func() screen {
698 s := screen{fields: []field{{"Queue", []cell{
699 cText(fmt.Sprintf("%d pending", queue.Pending)),
700 cMeta(fmt.Sprintf("%d claimed in 24h", queue.Claimed24h), "wait avg "+c.Term.dur(queue.ClaimWaitAvgS),
701 "max "+c.Term.dur(queue.ClaimWaitMaxS), fmt.Sprintf("%d reaped", queue.Reaped24h)),
702 }}}}
703 runnersSec := section{title: "Runners", n: len(runners)}
704 idle := ""
705 for _, r := range runners {
706 scope := r.Scope
707 if scope == "" {
708 scope = "any"
709 }
710 lead, held := cGlyph(""), "idle"
711 if r.BuildNumber != 0 {
712 lead, held = cGlyph("running"), fmt.Sprintf("building %s #%d %s", r.BuildRepo, r.BuildNumber, r.BuildJob)
713 } else if idle == "" {
714 idle = r.Fingerprint
715 }
716 runnersSec.rows = append(runnersSec.rows, rowOf(cRef(r.Username), lead, cFlex(scope), cMeta("seen "+relAge(r.LastSeen, termNow()), held)))
717 }
718 s.sections = []section{runnersSec}
719 if idle != "" {
720 s.actions = []action{{"Prune", []string{"admin", "runners", "remove", idle}}}
721 }
722 return s
602723 })
603724}
604725
@@ -687,7 +808,7 @@ func runAdminMRPrune(c *Ctx, args []string) int {
687808 if err := gitutil.PruneNow(dir); err != nil {
688809 return c.fail(protocol.ExitFailure, "%v; the head refs are deleted but the objects are not yet pruned; re-run the same command", err)
689810 }
690 return c.emit(rows, func(w io.Writer) {
811 return c.emitView(rows, func(w io.Writer) {
691812 tb := c.table(w, "!", "HEAD")
692813 for _, r := range rows {
693814 if r.Head == "" {
@@ -697,5 +818,21 @@ func runAdminMRPrune(c *Ctx, args []string) int {
697818 tb.row(cRef(fmt.Sprintf("!%d", r.Number)), cRef(r.Head))
698819 }
699820 tb.flush()
821 }, func() screen {
822 rs := make([]row, len(rows))
823 for i, r := range rows {
824 n := strconv.FormatInt(r.Number, 10)
825 ref := cLink("!"+n, c.siteURL(repo.Path(), "mrs", n))
826 if r.Head == "" {
827 rs[i] = rowOf(ref, cGlyph("skipped"), cMeta("already gone"))
828 continue
829 }
830 rs[i] = rowOf(ref, cGlyph("ok"), cRef(fmt.Sprintf("%.10s", r.Head)))
831 }
832 s := listScreen("Pruned", rs)
833 if len(rows) > 0 {
834 s.actions = []action{{"Read", []string{"mr", "show", repo.Path(), strconv.FormatInt(rows[0].Number, 10)}}}
835 }
836 return s
700837 })
701838}
internal/control/adminhost.go +27 −1
@@ -1,10 +1,12 @@
11package control
22
33import (
4 "cmp"
45 "errors"
56 "fmt"
67 "io"
78 "os"
9 "slices"
810
911 "golang.org/x/crypto/ssh"
1012
@@ -324,7 +326,7 @@ func runAdminStats(c *Ctx, args []string) int {
324326 if fi, err := os.Stat(c.Cfg.Server.Root + "/gitbay.db"); err == nil {
325327 d.DBBytes = fi.Size()
326328 }
327 return c.emit(d, func(w io.Writer) {
329 return c.emitView(d, func(w io.Writer) {
328330 v := c.view(w)
329331 v.fields(
330332 "users", fmt.Sprintf("%d", counts.Users),
@@ -344,6 +346,30 @@ func runAdminStats(c *Ctx, args []string) int {
344346 }
345347 tb.flush()
346348 }
349 }, func() screen {
350 count := func(all, open int64) []cell {
351 return []cell{cText(fmt.Sprint(all)), cMeta(fmt.Sprintf("%d open", open))}
352 }
353 s := screen{fields: []field{
354 {"Users", []cell{cText(fmt.Sprint(counts.Users))}},
355 {"Orgs", []cell{cText(fmt.Sprint(counts.Orgs))}},
356 {"Repos", []cell{cText(fmt.Sprint(counts.Repos))}},
357 {"Issues", count(counts.Issues, counts.OpenIssues)},
358 {"MRs", count(counts.MRs, counts.OpenMRs)},
359 {"Disk", []cell{cSize(d.DBBytes), cMeta("database", "repositories "+humanBytes(d.RepoBytes), "lfs "+humanBytes(d.LFSBytes))}},
360 }}
361 repos := slices.Clone(d.Repos)
362 slices.SortStableFunc(repos, func(a, b repoDisk) int { return cmp.Compare(b.Bytes, a.Bytes) })
363 top := section{title: "Repositories", n: len(repos), more: []string{"admin", "repo", "list"}}
364 for _, r := range repos[:min(20, len(repos))] {
365 top.rows = append(top.rows, rowOf(cLink(r.Path, c.siteURL(r.Path)), cSize(r.Bytes)))
366 }
367 s.sections = []section{top}
368 s.actions = []action{
369 {"Admin", []string{"admin", "user", "list"}},
370 {"Admin", []string{"admin", "runners"}},
371 }
372 return s
347373 })
348374}
349375
internal/control/adminmail.go +20 −2
@@ -43,8 +43,10 @@ func runAdminMailInboundCheck(c *Ctx, args []string) int {
4343 Warning string `json:"warning,omitempty"`
4444 }
4545 if !in.Enabled {
46 return c.emit(out{}, func(w io.Writer) {
46 return c.emitView(out{}, func(w io.Writer) {
4747 fmt.Fprintln(w, "inbound mail is off ([mail.inbound] enabled = false)")
48 }, func() screen {
49 return screen{fields: []field{{"Inbound", []cell{cText("off")}}}}
4850 })
4951 }
5052 cl, n, err := imapc.Open(in, true, 30*time.Second)
@@ -62,7 +64,7 @@ func runAdminMailInboundCheck(c *Ctx, args []string) int {
6264 d.Warning = unauthenticatedWarning
6365 fmt.Fprintln(c.Stderr, "warning: "+d.Warning)
6466 }
65 return c.emit(d, func(w io.Writer) {
67 return c.emitView(d, func(w io.Writer) {
6668 c.view(w).fields(
6769 "server", d.Server,
6870 "mailbox", d.Mailbox,
@@ -71,5 +73,21 @@ func runAdminMailInboundCheck(c *Ctx, args []string) int {
7173 "require_dkim", fmt.Sprintf("%t", d.RequireDKIM),
7274 "trusted_authserv_id", d.TrustedAuthservID,
7375 )
76 }, func() screen {
77 auth := cMark("✓ dkim", sgrGreen)
78 if d.Warning != "" {
79 auth = cMark("✗ "+d.Warning, sgrRed)
80 } else if !d.RequireDKIM {
81 auth = cMeta("dkim not required")
82 }
83 s := screen{fields: []field{
84 {"Inbound", []cell{cText(d.Server), cMeta(d.Mailbox)}},
85 {"Messages", []cell{cText(fmt.Sprint(d.Messages)), cMeta(fmt.Sprintf("%d unseen", d.Unseen))}},
86 {"Auth", []cell{auth}},
87 }}
88 if d.TrustedAuthservID != "" {
89 s.fields = append(s.fields, field{"Authserv", []cell{cText(d.TrustedAuthservID)}})
90 }
91 return s
7492 })
7593}
internal/control/audit.go +12 −6
@@ -54,20 +54,26 @@ func runAudit(c *Ctx, args []string) int {
5454 if err != nil {
5555 return c.fail(protocol.ExitFailure, "%v", err)
5656 }
57 return c.emit(entries, func(w io.Writer) {
57 return c.emitView(entries, func(w io.Writer) {
5858 tb := c.table(w, "WHEN", "ACTOR", "ACTION", "DATA")
5959 for _, e := range entries {
6060 actor := e.Actor
6161 if actor == "" {
6262 actor = "-"
6363 }
64 data := e.Data
65 if c.Term.Cols > 0 {
66 data = keyValues(data)
67 }
68 tb.row(cAge(e.CreatedAt), cText(actor), cText(e.Action), cFlex(data))
64 tb.row(cAge(e.CreatedAt), cText(actor), cText(e.Action), cFlex(e.Data))
6965 }
7066 tb.flush()
67 }, func() screen {
68 rows := make([]row, len(entries))
69 for i, e := range entries {
70 actor := e.Actor
71 if actor == "" {
72 actor = "-"
73 }
74 rows[i] = rowOf(cAge(e.CreatedAt), cText(actor), cText(e.Action), cFlex(keyValues(e.Data)))
75 }
76 return listScreen("Audit", rows, action{"Filter", []string{"audit", "--since", "24h"}})
7177 })
7278}
7379
internal/control/build.go +18 −1
@@ -456,7 +456,7 @@ func runBuildJobs(c *Ctx, args []string) int {
456456 for _, j := range jobs {
457457 out = append(out, JobOut{Name: j.Name, Schedule: j.Schedule, Tags: j.Tags})
458458 }
459 return c.emit(out, func(w io.Writer) {
459 return c.emitView(out, func(w io.Writer) {
460460 tb := c.table(w, "NAME", "WHEN")
461461 for _, j := range out {
462462 when := "on push"
@@ -469,6 +469,23 @@ func runBuildJobs(c *Ctx, args []string) int {
469469 tb.row(cRef(j.Name), cText(when))
470470 }
471471 tb.flush()
472 }, func() screen {
473 rows := make([]row, len(out))
474 for i, j := range out {
475 when := "on push"
476 switch {
477 case j.Schedule != "":
478 when = "schedule " + j.Schedule
479 case j.Tags != "":
480 when = "tags " + j.Tags
481 }
482 rows[i] = rowOf(cRef(j.Name), cMeta(when))
483 }
484 s := listScreen("Jobs", rows)
485 if len(out) > 0 {
486 s.actions = []action{{"Run", []string{"build", "trigger", repo.Path(), out[0].Name}}}
487 }
488 return s
472489 })
473490}
474491
internal/control/dashboard.go +7 −9
@@ -357,20 +357,18 @@ func runFeed(c *Ctx, args []string) int {
357357 })
358358 ds := feedOutputs(events)
359359 lines := FeedLines(events)
360 return c.emitPage(p, ds, next, func(w io.Writer) {
361 if c.Term.Cols > 0 {
362 tb := c.table(w, feedHeader...)
363 for _, l := range lines {
364 tb.row(l.termCells(c)...)
365 }
366 tb.flush()
367 return
368 }
360 return c.emitPageView(p, ds, next, func(w io.Writer) {
369361 tb := c.table(w, "WHEN", "EVENT")
370362 for _, l := range lines {
371363 tb.row(cAge(l.When), cFlex(l.Sentence()))
372364 }
373365 tb.flush()
366 }, func() screen {
367 rows := make([]row, len(lines))
368 for i, l := range lines {
369 rows[i] = rowOf(l.termCells(c)...)
370 }
371 return listScreen("Activity", rows)
374372 })
375373}
376374
internal/control/explore.go +15 −1
@@ -85,12 +85,26 @@ func runExplore(c *Ctx, args []string) int {
8585 }
8686 }
8787 ds, next := trimPage(p, ds, "explore", func(o out) string { return o.Path })
88 return c.emitPage(p, ds, next, func(w io.Writer) {
88 return c.emitPageView(p, ds, next, func(w io.Writer) {
8989 tb := c.table(w, "PATH", "DESCRIPTION")
9090 for _, d := range ds {
9191 tb.row(cRef(d.Path), cFlex(d.Description))
9292 }
9393 tb.flush()
94 }, func() screen {
95 rows := make([]row, len(ds))
96 for i, d := range ds {
97 archived := ""
98 if d.Archived {
99 archived = "archived"
100 }
101 rows[i] = rowOf(cLink(d.Path, c.siteURL(d.Path)), cFlex(d.Description), cMeta(strings.Join(d.Topics, ", "), archived))
102 }
103 s := listScreen("Explore", rows)
104 if len(ds) > 0 {
105 s.actions = []action{{"Read", []string{"repo", "show", ds[0].Path}}}
106 }
107 return s
94108 })
95109}
96110
internal/control/feedline.go +3 −5
@@ -42,11 +42,9 @@ func (l FeedLine) Sentence() string {
4242 return s
4343}
4444
45// feedHeader and termCells are a feed line as a terminal table shows
46// it: who, what, where, and how a build came out, so the outcome and the
47// reference can carry their colours.
48var feedHeader = []string{"WHEN", "WHO", "EVENT", "WHERE", "DETAIL"}
49
45// termCells is a feed line as a screen row shows it: when, who, what,
46// where, and how a build came out, so the outcome and the reference can
47// carry their colours.
5048func (l FeedLine) termCells(c *Ctx) []cell {
5149 ref := l.Ref
5250 if ref != "" && ref[0] != '#' && ref[0] != '!' {
internal/control/milestone.go +9 −1
@@ -293,12 +293,20 @@ func runIssueTemplates(c *Ctx, args []string) int {
293293 }
294294 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
295295 ts := IssueTemplates(dir, repo.DefaultBranch)
296 return c.emit(ts, func(w io.Writer) {
296 return c.emitView(ts, func(w io.Writer) {
297297 tb := c.table(w, "NAME")
298298 for _, t := range ts {
299299 tb.row(cRef(t.Name))
300300 }
301301 tb.flush()
302 }, func() screen {
303 rows := make([]row, len(ts))
304 for i, tpl := range ts {
305 rows[i] = rowOf(cRef(tpl.Name))
306 }
307 return listScreen("Issue templates", rows,
308 action{"New", []string{"issue", "create", repo.Path(), "--title", "<title>"}},
309 )
302310 })
303311}
304312
internal/control/mr.go +17 −1
@@ -2089,7 +2089,7 @@ func runMRRevisions(c *Ctx, args []string) int {
20892089 if err != nil {
20902090 return c.fail(protocol.ExitFailure, "%v", err)
20912091 }
2092 return c.emit(revs, func(w io.Writer) {
2092 return c.emitView(revs, func(w io.Writer) {
20932093 tb := c.table(w, "REV", "SHA", "WHEN")
20942094 for _, r := range revs {
20952095 mark := " "
@@ -2103,6 +2103,22 @@ func runMRRevisions(c *Ctx, args []string) int {
21032103 fmt.Fprintf(c.Stderr, "only one revision; %s!%d has not been pushed to since it was opened\n",
21042104 repo.Path(), mr.Number)
21052105 }
2106 }, func() screen {
2107 rows := make([]row, len(revs))
2108 for i, r := range revs {
2109 lead := cell{kind: kindGlyph}
2110 if r.Current {
2111 lead = cell{kind: kindGlyph, s: "●"}
2112 }
2113 rows[i] = rowOf(cRef(fmt.Sprintf("v%d", r.N)), lead, cRef(fmt.Sprintf("%.10s", r.SHA)), cAge(r.CreatedAt))
2114 }
2115 if len(revs) < 2 {
2116 fmt.Fprintf(c.Stderr, "only one revision; %s!%d has not been pushed to since it was opened\n",
2117 repo.Path(), mr.Number)
2118 }
2119 return listScreen("Revisions", rows,
2120 action{"Compare", []string{"mr", "range-diff", repo.Path(), strconv.FormatInt(mr.Number, 10)}},
2121 )
21062122 })
21072123}
21082124
internal/control/query.go +55 −6
@@ -193,7 +193,7 @@ func savedQuery(c *Ctx, name string) (store.SavedQuery, ItemQuery, int) {
193193
194194// runItemQuery lists what q matches in the tables issues and mrs allow,
195195// one page at a time. The output is always the paged shape.
196func runItemQuery(c *Ctx, q ItemQuery, issues, mrs bool, p page) int {
196func runItemQuery(c *Ctx, q ItemQuery, issues, mrs bool, p page, title string) int {
197197 var after *store.ItemCursor
198198 if p.key != "" {
199199 var err error
@@ -215,7 +215,7 @@ func runItemQuery(c *Ctx, q ItemQuery, issues, mrs bool, p page) int {
215215 next = encodeItemCursor(items[len(items)-1].Cursor())
216216 }
217217 ds := queryItems(items)
218 return c.emitPage(p, ds, next, func(w io.Writer) {
218 return c.emitPageView(p, ds, next, func(w io.Writer) {
219219 tb := c.table(w, "REF", "STATE", "TITLE", "AUTHOR")
220220 for _, d := range ds {
221221 state := d.State
@@ -225,6 +225,25 @@ func runItemQuery(c *Ctx, q ItemQuery, issues, mrs bool, p page) int {
225225 tb.row(cRef(d.Ref()), cState(state), cFlex(d.Title), cText(d.Author))
226226 }
227227 tb.flush()
228 }, func() screen {
229 rows := make([]row, len(ds))
230 for i, d := range ds {
231 state := d.State
232 if d.Draft {
233 state = "draft"
234 }
235 page := "issues"
236 if d.Kind == "mr" {
237 page = "mrs"
238 }
239 rows[i] = rowOf(cLink(d.Ref(), c.siteURL(d.Repo, page, strconv.FormatInt(d.Number, 10))), cGlyph(state), cFlex(d.Title),
240 cMeta(d.Author, d.Milestone, relAge(d.UpdatedAt, termNow())))
241 }
242 s := listScreen(title, rows)
243 if len(ds) > 0 {
244 s.actions = []action{{"Read", []string{ds[0].Kind, "show", ds[0].Repo, strconv.FormatInt(ds[0].Number, 10)}}}
245 }
246 return s
228247 })
229248}
230249
@@ -273,7 +292,7 @@ func listByQuery(c *Ctx, fl flags, kind string, p page) int {
273292 if kind == "mr" && !mrs {
274293 return c.usageWith("that query matches only issues; use issue list or query run")
275294 }
276 return runItemQuery(c, q, kind == "issue", kind == "mr", p)
295 return runItemQuery(c, q, kind == "issue", kind == "mr", p, "Results")
277296}
278297
279298func savedQueryOut(sq store.SavedQuery) SavedQueryOut {
@@ -335,7 +354,7 @@ func runQueryList(c *Ctx, args []string) int {
335354 for _, sq := range saved {
336355 ds = append(ds, savedQueryOut(sq))
337356 }
338 return c.emit(ds, func(w io.Writer) {
357 return c.emitView(ds, func(w io.Writer) {
339358 tb := c.table(w, "NAME", "PINNED", "QUERY")
340359 for _, d := range ds {
341360 pinned := ""
@@ -345,6 +364,23 @@ func runQueryList(c *Ctx, args []string) int {
345364 tb.row(cRef(d.Name), cText(pinned), cFlex(d.Query))
346365 }
347366 tb.flush()
367 }, func() screen {
368 rows := make([]row, len(ds))
369 for i, d := range ds {
370 pinned := ""
371 if d.Pinned {
372 pinned = "pinned"
373 }
374 rows[i] = rowOf(cRef(d.Name), cFlex(d.Query), cMeta(pinned))
375 }
376 s := listScreen("Saved queries", rows)
377 if len(ds) > 0 {
378 s.actions = []action{
379 {"Queries", []string{"query", "run", ds[0].Name}},
380 {"Queries", []string{"query", "pin", ds[0].Name}},
381 }
382 }
383 return s
348384 })
349385}
350386
@@ -362,7 +398,7 @@ func runQueryShow(c *Ctx, args []string) int {
362398 }
363399 d := savedQueryOut(sq)
364400 d.Count = &n
365 return c.emit(d, func(w io.Writer) {
401 return c.emitView(d, func(w io.Writer) {
366402 v := c.view(w)
367403 v.title(d.Name, "", "")
368404 pinned := "no"
@@ -370,6 +406,19 @@ func runQueryShow(c *Ctx, args []string) int {
370406 pinned = "yes"
371407 }
372408 v.fields("query", d.Query, "matches", strconv.Itoa(n), "pinned", pinned)
409 }, func() screen {
410 pin, pinned := "pin", "no"
411 if d.Pinned {
412 pin, pinned = "unpin", "yes"
413 }
414 return screen{fields: []field{
415 {"Query", []cell{cRef(d.Name), cText(d.Query)}},
416 {"Matches", []cell{cText(strconv.Itoa(n))}},
417 {"Pinned", []cell{cText(pinned)}},
418 }, actions: []action{
419 {"Queries", []string{"query", "run", d.Name}},
420 {"Queries", []string{"query", pin, d.Name}},
421 }}
373422 })
374423}
375424
@@ -385,7 +434,7 @@ func runQueryRun(c *Ctx, args []string) int {
385434 if code >= 0 {
386435 return code
387436 }
388 return runItemQuery(c, q, true, true, p)
437 return runItemQuery(c, q, true, true, p, rest[0])
389438}
390439
391440func runQueryRemove(c *Ctx, args []string) int {
internal/control/release.go +39 −27
@@ -282,26 +282,7 @@ func runReleaseList(c *Ctx, args []string) int {
282282 for _, r := range rels {
283283 ds = append(ds, releaseToOut(r, false))
284284 }
285 return c.emitPage(p, ds, next, func(w io.Writer) {
286 if c.Term.Cols > 0 {
287 tb := c.table(w, "TAG", "TITLE", "ASSETS", "RELEASED")
288 for _, d := range ds {
289 // "v1.2.0 — the forge speaks first" reads as its
290 // subtitle beside the tag.
291 title := strings.TrimPrefix(strings.TrimPrefix(d.Title, d.Tag), " — ")
292 assets := ""
293 switch n := len(d.Assets); n {
294 case 0:
295 case 1:
296 assets = "1 asset"
297 default:
298 assets = fmt.Sprintf("%d assets", n)
299 }
300 tb.row(cLink(d.Tag, c.siteURL(repo.Path(), "releases")), cFlex(title), cText(assets), cAge(d.CreatedAt))
301 }
302 tb.flush()
303 return
304 }
285 return c.emitPageView(p, ds, next, func(w io.Writer) {
305286 tb := c.table(w, "TAG", "TITLE", "ASSETS")
306287 for _, d := range ds {
307288 title := d.Title
@@ -311,16 +292,37 @@ func runReleaseList(c *Ctx, args []string) int {
311292 tb.row(cRef(d.Tag), cFlex(title), cText(fmt.Sprintf("%d asset(s)", len(d.Assets))))
312293 }
313294 tb.flush()
295 }, func() screen {
296 rows := make([]row, len(ds))
297 for i, d := range ds {
298 // "v1.2.0 — the forge speaks first" reads as its subtitle
299 // beside the tag.
300 title := strings.TrimPrefix(strings.TrimPrefix(d.Title, d.Tag), " — ")
301 assets := ""
302 switch n := len(d.Assets); n {
303 case 0:
304 case 1:
305 assets = "1 asset"
306 default:
307 assets = fmt.Sprintf("%d assets", n)
308 }
309 rows[i] = rowOf(cLink(d.Tag, c.siteURL(repo.Path(), "releases")), cFlex(title), cMeta(assets, relAge(d.CreatedAt, termNow())))
310 }
311 s := listScreen("Releases", rows)
312 if len(ds) > 0 {
313 s.actions = []action{{"Read", []string{"release", "show", repo.Path(), ds[0].Tag}}}
314 }
315 return s
314316 })
315317}
316318
317319func runReleaseShow(c *Ctx, args []string) int {
318 _, rel, code := releaseRef(c, args, policy.CanRead)
320 repo, rel, code := releaseRef(c, args, policy.CanRead)
319321 if code >= 0 {
320322 return code
321323 }
322324 d := releaseToOut(rel, true)
323 return c.emit(d, func(w io.Writer) {
325 return c.emitView(d, func(w io.Writer) {
324326 title := d.Title
325327 if title == d.Tag {
326328 title = ""
@@ -335,14 +337,24 @@ func runReleaseShow(c *Ctx, args []string) int {
335337 v.section("assets")
336338 tb := c.table(w, "NAME", "SIZE", "SHA256")
337339 for _, a := range d.Assets {
338 sum := a.SHA256
339 if c.Term.Cols > 0 {
340 sum = sum[:min(12, len(sum))]
341 }
342 tb.row(cRef(a.Name), cSize(a.Size), cFlex(sum))
340 tb.row(cRef(a.Name), cSize(a.Size), cFlex(a.SHA256))
343341 }
344342 tb.flush()
345343 }
344 }, func() screen {
345 assets := section{title: "Assets", n: len(d.Assets)}
346 for _, a := range d.Assets {
347 assets.rows = append(assets.rows, rowOf(cRef(a.Name), cSize(a.Size), cMeta(a.SHA256[:min(12, len(a.SHA256))])))
348 }
349 s := screen{body: d.Notes, format: d.NotesFormat, sections: []section{assets}, fields: []field{
350 {"Release", []cell{cLink(d.Tag, c.siteURL(repo.Path(), "releases")), cText(d.Title)}},
351 {"Author", []cell{cText(d.Author), cAge(d.CreatedAt)}},
352 }}
353 if len(d.Assets) > 0 {
354 s.actions = append(s.actions, action{"Get", []string{"release", "asset", "get", repo.Path(), d.Tag, d.Assets[0].Name}})
355 }
356 s.actions = append(s.actions, action{"Edit", []string{"release", "edit", repo.Path(), d.Tag, "--title", "<title>"}})
357 return s
346358 })
347359}
348360
internal/control/search.go +30 −6
@@ -105,7 +105,9 @@ func runSearch(c *Ctx, args []string) int {
105105 if err != nil {
106106 return c.fail(protocol.ExitFailure, "%v", err)
107107 }
108 return c.emit(results, func(w io.Writer) { writeSearchTable(c, w, results) })
108 return c.emitView(results, func(w io.Writer) { writeSearchTable(c, w, results) }, func() screen {
109 return searchScreen(c, f.Pos[0], results)
110 })
109111}
110112
111113// writeSearchTable renders search results: a repo hit has no ref number or
@@ -118,11 +120,7 @@ func writeSearchTable(c *Ctx, w io.Writer, results []SearchResult) {
118120 for _, r := range results {
119121 switch r.Kind {
120122 case "repo":
121 if c.Term.Cols > 0 {
122 tb.row(cText("repo"), cLink(r.Repo, c.siteURL(r.Repo)), cState(""), cFlex(r.Title))
123 } else {
124 tb.row(cText("repo"), cRef(r.Repo), cFlex(r.Title))
125 }
123 tb.row(cText("repo"), cRef(r.Repo), cFlex(r.Title))
126124 default:
127125 page := "issues"
128126 if r.Kind == "mr" {
@@ -134,6 +132,32 @@ func writeSearchTable(c *Ctx, w io.Writer, results []SearchResult) {
134132 tb.flush()
135133}
136134
135// searchScreen is search at a terminal: a section per kind of result.
136func searchScreen(c *Ctx, query string, results []SearchResult) screen {
137 repos := section{title: "Repositories"}
138 issues := section{title: "Issues"}
139 mrs := section{title: "Merge requests"}
140 for _, r := range results {
141 switch r.Kind {
142 case "repo":
143 repos.n++
144 repos.rows = append(repos.rows, rowOf(cLink(r.Repo, c.siteURL(r.Repo)), cFlex(r.Title)))
145 default:
146 sec, page := &issues, "issues"
147 if r.Kind == "mr" {
148 sec, page = &mrs, "mrs"
149 }
150 sec.n++
151 ref := cLink(fmt.Sprintf("%s%s%d", r.Repo, SearchMarker(r.Kind), r.Number), c.siteURL(r.Repo, page, strconv.FormatInt(r.Number, 10)))
152 sec.rows = append(sec.rows, rowOf(ref, cGlyph(r.State), cFlex(r.Title), cMeta(r.Author)))
153 }
154 }
155 return screen{sections: []section{repos, issues, mrs}, actions: []action{
156 {"Narrow", []string{"search", query, "--kind", "issue"}},
157 {"Narrow", []string{"search", query, "--kind", "mr"}},
158 }}
159}
160
137161// SearchMarker is the sigil a result's number carries, shared with the web
138162// so a hit reads the same in both places.
139163func SearchMarker(kind string) string {
internal/control/search_test.go −27
@@ -2,36 +2,9 @@ package control
22
33import (
44 "bytes"
5 "strings"
65 "testing"
76)
87
9// A repo hit has no state, an issue/mr hit does. At a terminal both
10// titles must start in the same column, not have the repo's title slide
11// left into the state column.
12func TestSearchTableAlignsTitleAtTerminal(t *testing.T) {
13 results := []SearchResult{
14 {Kind: "repo", Repo: "alice/webapp", Title: "a web application"},
15 {Kind: "issue", Repo: "alice/webapp", Number: 4, Title: "memory leak", State: "open"},
16 }
17 var b bytes.Buffer
18 writeSearchTable(&Ctx{Term: Term{Cols: 100}}, &b, results)
19
20 lines := strings.Split(strings.TrimRight(b.String(), "\n"), "\n")
21 if len(lines) != 2 {
22 t.Fatalf("want 2 rows, got %d lines:\n%s", len(lines), b.String())
23 }
24 repoRow, issueRow := lines[0], lines[1]
25 repoTitleAt := strings.Index(repoRow, "a web application")
26 issueTitleAt := strings.Index(issueRow, "memory leak")
27 if repoTitleAt < 0 || issueTitleAt < 0 {
28 t.Fatalf("columns not found:\n%s", b.String())
29 }
30 if repoTitleAt != issueTitleAt {
31 t.Errorf("titles not aligned: repo row at %d, issue row at %d\n%s", repoTitleAt, issueTitleAt, b.String())
32 }
33}
34
358// Plain output has no header to align to, so a repo row stays 3 cells —
369// bytes must not change from before the terminal fix.
3710func TestSearchTablePlainRepoRowIsThreeCells(t *testing.T) {
internal/control/snippet.go +32 −2
@@ -7,6 +7,7 @@ import (
77 "fmt"
88 "io"
99 "strconv"
10 "strings"
1011 "unicode/utf8"
1112
1213 "gitbay.org/gitbay/internal/policy"
@@ -222,7 +223,7 @@ func runSnippetShow(c *Ctx, args []string) int {
222223 return c.fail(protocol.ExitFailure, "%v", err)
223224 }
224225 sn.Files = files
225 return c.emit(snippetOut(c, sn), func(w io.Writer) {
226 return c.emitView(snippetOut(c, sn), func(w io.Writer) {
226227 v := c.view(w)
227228 v.title(sn.PublicID, sn.Description, sn.Visibility)
228229 v.fields(
@@ -238,6 +239,21 @@ func runSnippetShow(c *Ctx, args []string) int {
238239 }
239240 tb.flush()
240241 }
242 }, func() screen {
243 fs := section{title: "Files", n: len(files)}
244 for _, f := range files {
245 fs.rows = append(fs.rows, rowOf(cRef(f.Name), cSize(int64(f.Size))))
246 }
247 s := screen{sections: []section{fs}, fields: []field{
248 {"Snippet", []cell{cLink(sn.PublicID, snippetURL(c, sn)), cText(sn.Description)}},
249 {"Owner", []cell{cText(sn.OwnerName), cState(sn.Visibility)}},
250 {"Updated", []cell{cAge(sn.UpdatedAt)}},
251 }}
252 if len(files) > 0 {
253 s.actions = append(s.actions, action{"Get", []string{"snippet", "file", "get", sn.PublicID, files[0].Name}})
254 }
255 s.actions = append(s.actions, action{"Edit", []string{"snippet", "edit", sn.PublicID, "--description", "<text>"}})
256 return s
241257 })
242258}
243259
@@ -270,7 +286,7 @@ func runSnippetList(c *Ctx, args []string) int {
270286 for _, sn := range rows {
271287 items = append(items, snippetOut(c, sn))
272288 }
273 return c.emitPage(p, items, next, func(w io.Writer) {
289 return c.emitPageView(p, items, next, func(w io.Writer) {
274290 tb := c.table(w, "ID", "VISIBILITY", "FILES", "DESCRIPTION")
275291 for _, sn := range rows {
276292 names := ""
@@ -283,6 +299,20 @@ func runSnippetList(c *Ctx, args []string) int {
283299 tb.row(cRef(sn.PublicID), cState(sn.Visibility), cText(names), cFlex(sn.Description))
284300 }
285301 tb.flush()
302 }, func() screen {
303 rs := make([]row, len(rows))
304 for i, sn := range rows {
305 names := make([]string, len(sn.Files))
306 for j, f := range sn.Files {
307 names[j] = f.Name
308 }
309 rs[i] = rowOf(cRef(sn.PublicID), cState(sn.Visibility), cFlex(sn.Description), cMeta(strings.Join(names, ", "), relAge(sn.UpdatedAt, termNow())))
310 }
311 s := listScreen("Snippets", rs)
312 if len(rows) > 0 {
313 s.actions = []action{{"Read", []string{"snippet", "show", rows[0].PublicID}}}
314 }
315 return s
286316 })
287317}
288318
internal/control/stage3admin_test.go added +70
@@ -0,0 +1,70 @@
1package control
2
3import (
4 "regexp"
5 "strings"
6 "testing"
7
8 "gitbay.org/gitbay/internal/store"
9)
10
11var diskBytes = regexp.MustCompile(`\t\d{4,}\t|\d+\.\d KiB`)
12
13// adminFixture is the work fixture with alice as an instance admin, a
14// second account, and a closed merge request to prune.
15func adminFixture(t *testing.T) (*store.Store, store.Repo, store.User, string) {
16 t.Helper()
17 st, repo, u, root, _ := workFixture(t)
18 if err := st.SetUserAdmin(u.ID, true); err != nil {
19 t.Fatal(err)
20 }
21 u.IsAdmin = true
22 dispatchIn(t, st, u, root, "", "admin", "user", "create", "bob")
23 dispatchIn(t, st, u, root, "", "mr", "close", repo.Path(), "1")
24 return st, repo, u, root
25}
26
27func TestAdminPlainPinned(t *testing.T) {
28 st, repo, u, root := adminFixture(t)
29 for name, argv := range map[string][]string{
30 "admin-user-list": {"admin", "user", "list"},
31 "admin-user-show": {"admin", "user", "show", "alice"},
32 "admin-runners": {"admin", "runners"},
33 "admin-repo-list": {"admin", "repo", "list"},
34 "admin-stats": {"admin", "stats"},
35 "admin-mail-inbound-check": {"admin", "mail", "inbound", "check"},
36 } {
37 got := dispatchIn(t, st, u, root, "", argv...)
38 // A repository's size on disk moves by a few bytes between runs.
39 got = diskBytes.ReplaceAllString(got, "<size>")
40 pinPlain(t, name, got)
41 }
42 pinPlain(t, "admin-mr-prune", dispatchIn(t, st, u, root, "", "admin", "mr", "prune", repo.Path(), "1", "--yes"))
43}
44
45func TestAdminScreens(t *testing.T) {
46 st, repo, u, root := adminFixture(t)
47 for _, tc := range []struct {
48 argv []string
49 want []string
50 }{
51 {[]string{"admin", "user", "list"}, []string{"Accounts (3)\n", "alice", "bob", "admin"}},
52 {[]string{"admin", "user", "show", "alice"}, []string{"User:", "alice", "active", "gitbay admin user demote alice"}},
53 {[]string{"admin", "runners"}, []string{"Queue:", "0 pending"}},
54 {[]string{"admin", "repo", "list"}, []string{"Repositories (1)\n", "alice/app"}},
55 {[]string{"admin", "stats"}, []string{"Users:", "2", "Repos:", "Disk:"}},
56 {[]string{"admin", "mail", "inbound", "check"}, []string{"Inbound: off\n"}},
57 {[]string{"admin", "mr", "prune", repo.Path(), "1", "--yes"}, []string{"Pruned (1)\n", "!1", "already gone"}},
58 } {
59 out := atTerminalIn(t, st, u, root, tc.argv...)
60 for _, w := range tc.want {
61 if !strings.Contains(out, w) {
62 t.Errorf("%v: missing %q in:\n%s", tc.argv, w, out)
63 }
64 }
65 if strings.Contains(tc.argv[1], "mail") || tc.argv[1] == "runners" {
66 continue
67 }
68 checkLegend(t, out)
69 }
70}
internal/control/stage3repo_test.go +4
@@ -30,6 +30,8 @@ func dispatchIn(t *testing.T, st *store.Store, u store.User, root, stdin string,
3030 // Fixtures write a lot as user 1; the write limiter is global to
3131 // the package, so leave its budget to the tests that exercise it.
3232 c.Cfg.Limits.WriteRate = -1
33 c.Cfg.Limits.MaxSnippetBytes = 1 << 20
34 c.Cfg.Limits.MaxBlobBytes = 1 << 20
3335 if code := Dispatch(c, argv); code != protocol.ExitOK {
3436 t.Fatalf("%v: exit %d: %s", argv, code, errOut)
3537 }
@@ -52,6 +54,8 @@ func atTerminalIn(t *testing.T, st *store.Store, u store.User, root string, argv
5254 // Fixtures write a lot as user 1; the write limiter is global to
5355 // the package, so leave its budget to the tests that exercise it.
5456 c.Cfg.Limits.WriteRate = -1
57 c.Cfg.Limits.MaxSnippetBytes = 1 << 20
58 c.Cfg.Limits.MaxBlobBytes = 1 << 20
5559 if code := Dispatch(c, argv); code != protocol.ExitOK {
5660 t.Fatalf("%v: exit %d: %s", argv, code, errOut)
5761 }
internal/control/stage3work_test.go added +168
@@ -0,0 +1,168 @@
1package control
2
3import (
4 "os"
5 "os/exec"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "gitbay.org/gitbay/internal/store"
11)
12
13// workFixture is a repository on disk with a CI job and an issue
14// template, an issue, a merge request, a commit status and a saved
15// query, all as alice. It returns the head sha.
16func workFixture(t *testing.T) (*store.Store, store.Repo, store.User, string, string) {
17 t.Helper()
18 st, repo, uid := newQueueTestRepo(t)
19 u := store.User{ID: uid, Username: "alice"}
20 root := t.TempDir()
21 env := append(gitTestEnv(), "GIT_AUTHOR_DATE=2026-09-01T10:00:00Z", "GIT_COMMITTER_DATE=2026-09-01T10:00:00Z")
22 git := func(dir string, args ...string) string {
23 t.Helper()
24 cmd := exec.Command("git", args...)
25 cmd.Dir, cmd.Env = dir, env
26 out, err := cmd.CombinedOutput()
27 if err != nil {
28 t.Fatalf("git %v: %v\n%s", args, err, out)
29 }
30 return strings.TrimSpace(string(out))
31 }
32 src := filepath.Join(root, "src")
33 if err := os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755); err != nil {
34 t.Fatal(err)
35 }
36 os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte("jobs:\n test:\n steps:\n - go test ./...\n"), 0o644)
37 os.WriteFile(filepath.Join(src, ".gitbay", "issue-template-bug.md"), []byte("## Steps\n"), 0o644)
38 os.MkdirAll(filepath.Join(src, ".gitbay", "wiki"), 0o755)
39 os.WriteFile(filepath.Join(src, ".gitbay", "wiki", "Home.md"), []byte("# Welcome\n\nStart here.\n"), 0o644)
40 git(root, "init", "-q", "-b", "main", "src")
41 git(src, "add", ".")
42 git(src, "commit", "-q", "-m", "first")
43 sha := git(src, "rev-parse", "HEAD")
44 git(src, "tag", "v1.0.0")
45 dir := RepoDir(root, repo.OwnerName, repo.Name)
46 os.MkdirAll(filepath.Dir(dir), 0o755)
47 git(root, "clone", "-q", "--bare", src, dir)
48
49 p := repo.Path()
50 dispatchIn(t, st, u, root, "", "issue", "create", p, "--title", "Crash on start", "--body", "It crashes.")
51 if _, err := st.CreateMR(repo.ID, uid, repo.ID, "fix", "main", "Fix crash", "", sha, "md", false); err != nil {
52 t.Fatal(err)
53 }
54 dispatchIn(t, st, u, root, "", "status", "set", p, sha, "--context", "ext/test", "--state", "success", "--description", "all green")
55 dispatchIn(t, st, u, root, "", "query", "save", "open", "is:open")
56 return st, repo, u, root, sha
57}
58
59func TestWorkPlainPinnedA(t *testing.T) {
60 st, repo, u, root, sha := workFixture(t)
61 p := repo.Path()
62 for name, argv := range map[string][]string{
63 "query-run": {"query", "run", "open"},
64 "issue-list-q": {"issue", "list", "--q", "repo:alice/app is:open"},
65 "query-list": {"query", "list"},
66 "query-show": {"query", "show", "open"},
67 "search": {"search", "crash"},
68 "feed": {"feed"},
69 "mr-revisions": {"mr", "revisions", p, "1"},
70 "issue-templates": {"issue", "templates", p},
71 "build-jobs": {"build", "jobs", p},
72 "status-list": {"status", "list", p, sha},
73 } {
74 pinPlain(t, name, dispatchIn(t, st, u, root, "", argv...))
75 }
76}
77
78func TestWorkScreensA(t *testing.T) {
79 st, repo, u, root, sha := workFixture(t)
80 p := repo.Path()
81 for _, tc := range []struct {
82 argv []string
83 want []string
84 }{
85 {[]string{"query", "run", "open"}, []string{"open (2)\n", "alice/app#1", "Crash on start", "alice/app!1"}},
86 {[]string{"issue", "list", "--q", "repo:alice/app is:open"}, []string{"Results (1)\n", "Crash on start"}},
87 {[]string{"query", "list"}, []string{"Saved queries (1)\n", "open is:open"}},
88 {[]string{"query", "show", "open"}, []string{"Query:", "is:open", "Matches:", "2"}},
89 {[]string{"search", "crash"}, []string{"Issues (1)\n", "Merge requests (1)\n", "Crash on start"}},
90 {[]string{"feed"}, []string{"Activity ("}},
91 {[]string{"mr", "revisions", p, "1"}, []string{"Revisions (1)\n", "v1"}},
92 {[]string{"issue", "templates", p}, []string{"Issue templates (1)\nissue-template-bug.md"}},
93 {[]string{"build", "jobs", p}, []string{"Jobs (1)\ntest on push\n"}},
94 {[]string{"status", "list", p, sha}, []string{"Commit:", "Combined:", "✓", "Statuses (1)\n", "ext/test all green"}},
95 } {
96 out := atTerminalIn(t, st, u, root, tc.argv...)
97 for _, w := range tc.want {
98 if !strings.Contains(out, w) {
99 t.Errorf("%v: missing %q in:\n%s", tc.argv, w, out)
100 }
101 }
102 if tc.argv[0] != "feed" {
103 checkLegend(t, out)
104 }
105 }
106}
107
108func TestWorkPlainPinnedB(t *testing.T) {
109 st, repo, u, root, _ := workFixture(t)
110 p := repo.Path()
111 dispatchIn(t, st, u, root, "", "release", "create", p, "v1.0.0", "--title", "v1.0.0 — first", "--notes", "First release.")
112 dispatchIn(t, st, u, root, "package main\n", "snippet", "create", "main.go", "--description", "Hello")
113 snippets := dispatchIn(t, st, u, root, "", "snippet", "list")
114 id, _, _ := strings.Cut(snippets, "\t")
115 for name, argv := range map[string][]string{
116 "release-list": {"release", "list", p},
117 "release-show": {"release", "show", p, "v1.0.0"},
118 "snippet-list": {"snippet", "list"},
119 "snippet-show": {"snippet", "show", id},
120 "wiki-list": {"wiki", "list", p},
121 "wiki-show": {"wiki", "show", p, "Home"},
122 "explore": {"explore"},
123 } {
124 got := dispatchIn(t, st, u, root, "", argv...)
125 pinPlain(t, name, strings.ReplaceAll(got, id, "<id>"))
126 }
127}
128
129func TestWorkScreensB(t *testing.T) {
130 st, repo, u, root, _ := workFixture(t)
131 p := repo.Path()
132 dispatchIn(t, st, u, root, "", "release", "create", p, "v1.0.0", "--title", "v1.0.0 — first", "--notes", "First release.")
133 dispatchIn(t, st, u, root, "package main\n", "snippet", "create", "main.go", "--description", "Hello")
134 snippets := dispatchIn(t, st, u, root, "", "snippet", "list")
135 id, _, _ := strings.Cut(snippets, "\t")
136 for _, tc := range []struct {
137 argv []string
138 want []string
139 }{
140 {[]string{"release", "list", p}, []string{"Releases (1)\n", "v1.0.0 first"}},
141 {[]string{"release", "show", p, "v1.0.0"}, []string{"Release:", "v1.0.0 v1.0.0 — first", "First release."}},
142 {[]string{"snippet", "list"}, []string{"Snippets (1)\n", "Hello", "main.go"}},
143 {[]string{"snippet", "show", id}, []string{"Snippet:", "Hello", "Files (1)\nmain.go"}},
144 {[]string{"wiki", "list", p}, []string{"Wiki (1)\nHome", "home"}},
145 {[]string{"wiki", "show", p, "Home"}, []string{"Page:", "Home", "Welcome", "Start here."}},
146 {[]string{"explore"}, []string{"Explore (1)\nalice/app"}},
147 } {
148 out := atTerminalIn(t, st, u, root, tc.argv...)
149 for _, w := range tc.want {
150 if !strings.Contains(out, w) {
151 t.Errorf("%v: missing %q in:\n%s", tc.argv, w, out)
152 }
153 }
154 checkLegend(t, out)
155 }
156}
157
158func TestAuditScreen(t *testing.T) {
159 st, _, uid := newQueueTestRepo(t)
160 admin := store.User{ID: uid, Username: "alice", IsAdmin: true}
161 dispatchAs(t, st, admin, "", "admin", "user", "create", "bob")
162 pinPlain(t, "audit", dispatchAs(t, st, admin, "", "audit"))
163 out := atTerminal(t, st, admin, "audit")
164 if !strings.Contains(out, "Audit (") || !strings.Contains(out, "alice") {
165 t.Errorf("audit:\n%s", out)
166 }
167 checkLegend(t, out)
168}
internal/control/status.go +13 −1
@@ -137,13 +137,25 @@ func runStatusList(c *Ctx, args []string) int {
137137 Combined string `json:"combined"`
138138 Statuses []out `json:"statuses"`
139139 }{full, combinedOf(statuses), ds}
140 return c.emit(d, func(w io.Writer) {
140 return c.emitView(d, func(w io.Writer) {
141141 tb := c.table(w, "CONTEXT", "STATE", "DESCRIPTION")
142142 tb.row(cText("combined"), cState(orNone(d.Combined)), cText(fmt.Sprintf("%.10s", d.SHA)))
143143 for _, x := range ds {
144144 tb.row(cText(x.Context), cState(x.State), cFlex(x.Description))
145145 }
146146 tb.flush()
147 }, func() screen {
148 combined := []cell{cGlyph(d.Combined), cState(orNone(d.Combined))}
149 statuses := section{title: "Statuses", n: len(ds)}
150 for _, x := range ds {
151 statuses.rows = append(statuses.rows, rowOf(cGlyph(x.State), cText(x.Context), cFlex(x.Description), cMeta(x.Creator)))
152 }
153 return screen{fields: []field{
154 {"Commit", []cell{cRef(fmt.Sprintf("%.10s", d.SHA))}},
155 {"Combined", combined},
156 }, sections: []section{statuses}, actions: []action{
157 {"Read", []string{"repo", "commit", repo.Path(), fmt.Sprintf("%.10s", d.SHA)}},
158 }}
147159 })
148160}
149161
internal/control/term.go −10
@@ -194,16 +194,6 @@ func (t Term) paintState(s string) string {
194194 return strings.Join(words, ", ")
195195}
196196
197// heading is a section label at a terminal: capitalised, no trailing
198// colon, bold.
199func (t Term) heading(label string) string {
200 label = strings.TrimSuffix(label, ":")
201 if r, size := utf8.DecodeRuneInString(label); size > 0 {
202 label = string(unicode.ToUpper(r)) + label[size:]
203 }
204 return t.paint(sgrBold, label)
205}
206
207197// failure is a refusal as a terminal shows it: "error: " in red ahead
208198// of the message, and a usage line wrapped to the width between its
209199// bracketed groups, continuation lines indented under the command.
internal/control/term_test.go −6
@@ -117,12 +117,6 @@ func TestPaintStateEachWord(t *testing.T) {
117117 }
118118}
119119
120func TestHeading(t *testing.T) {
121 if got := (Term{Cols: 80}).heading("waiting on your review:"); got != "Waiting on your review" {
122 t.Errorf("heading = %q", got)
123 }
124}
125
126120func TestFailureAtTerminal(t *testing.T) {
127121 msg := "unknown flag \"--stat\"; did you mean --state?\nusage: gitbay issue list [<owner/name>] [--state open|closed|all] [--label <l>] [--assignee <user>]"
128122 got := Term{Cols: 50}.failure(msg)
internal/control/testdata/plain/admin-mail-inbound-check.txt added +1
@@ -0,0 +1 @@
1inbound mail is off ([mail.inbound] enabled = false)
internal/control/testdata/plain/admin-mr-prune.txt added +1
@@ -0,0 +1 @@
1!1 already gone
internal/control/testdata/plain/admin-repo-list.txt added +1
@@ -0,0 +1 @@
1alice/app public<size><time>
internal/control/testdata/plain/admin-runners.txt added +5
@@ -0,0 +1,5 @@
1 pending 0
2 claimed 24h 0
3 wait avg 0s
4 wait max 0s
5 reaped 24h 0
internal/control/testdata/plain/admin-stats.txt added +11
@@ -0,0 +1,11 @@
1 users 3
2 orgs 0
3 repos 1
4 issues 1 (1 open)
5 MRs 1 (0 open)
6 database 0 B
7 repositories <size>
8 lfs 0 B
9
10repos:
11alice/app <size>
internal/control/testdata/plain/admin-user-list.txt added +3
@@ -0,0 +1,3 @@
1alice active admin <time>
2bob active <time>
3gitbay-bot active <time>
internal/control/testdata/plain/admin-user-show.txt added +6
@@ -0,0 +1,6 @@
1alice active
2
3 admin yes
4 created <time>
5 repos 1
6 web sessions 0
internal/control/testdata/plain/audit.txt added +2
@@ -0,0 +1,2 @@
1<time> alice cmd admin user create {"argv":["bob"],"source":""}
2<time> alice admin user.created {"user":"bob"}
internal/control/testdata/plain/build-jobs.txt added +1
@@ -0,0 +1 @@
1test on push
internal/control/testdata/plain/explore.txt added +1
@@ -0,0 +1 @@
1alice/app
internal/control/testdata/plain/feed.txt added +2
@@ -0,0 +1,2 @@
1<time> alice status alice/app
2<time> alice opened issue alice/app#1
internal/control/testdata/plain/issue-list-q.txt added +1
@@ -0,0 +1 @@
1alice/app#1 open Crash on start alice
internal/control/testdata/plain/issue-templates.txt added +1
@@ -0,0 +1 @@
1issue-template-bug.md
internal/control/testdata/plain/mr-revisions.txt added +1
@@ -0,0 +1 @@
1* v1 a59131b356 <time>
internal/control/testdata/plain/query-list.txt added +1
@@ -0,0 +1 @@
1open is:open
internal/control/testdata/plain/query-run.txt added +2
@@ -0,0 +1,2 @@
1alice/app!1 open Fix crash alice
2alice/app#1 open Crash on start alice
internal/control/testdata/plain/query-show.txt added +5
@@ -0,0 +1,5 @@
1open
2
3 query is:open
4 matches 2
5 pinned no
internal/control/testdata/plain/release-list.txt added +1
@@ -0,0 +1 @@
1v1.0.0 v1.0.0 — first 0 asset(s)
internal/control/testdata/plain/release-show.txt added +5
@@ -0,0 +1,5 @@
1v1.0.0 v1.0.0 — first
2
3 author alice, <time>
4
5 First release.
internal/control/testdata/plain/search.txt added +2
@@ -0,0 +1,2 @@
1issue alice/app#1 open Crash on start
2mr alice/app!1 open Fix crash
internal/control/testdata/plain/snippet-list.txt added +1
@@ -0,0 +1 @@
1<id> unlisted main.go Hello
internal/control/testdata/plain/snippet-show.txt added +8
@@ -0,0 +1,8 @@
1<id> Hello unlisted
2
3 author alice
4 updated <time>
5 url /alice/-/snippets/<id>
6
7files:
8main.go 13 bytes
internal/control/testdata/plain/status-list.txt added +2
@@ -0,0 +1,2 @@
1combined success a59131b356
2ext/test success all green
internal/control/testdata/plain/wiki-list.txt added +1
@@ -0,0 +1 @@
1Home
internal/control/testdata/plain/wiki-show.txt added +3
@@ -0,0 +1,3 @@
1# Welcome
2
3Start here.
internal/control/view.go +23 −97
@@ -27,8 +27,10 @@ func (c *Ctx) siteURL(parts ...string) string {
2727 return strings.TrimRight(c.Cfg.Server.SiteURL, "/") + "/" + strings.Join(parts, "/")
2828}
2929
30// view lays out a show: a title line, aligned fields, a body, events,
31// comments. Plain output is the same lines without colour or wrapping.
30// view lays out a show piped: a title line, aligned fields, a body,
31// events, comments. A terminal draws a screen instead (screen.go); a
32// caller that still reaches view at a terminal gets the same plain
33// layout, with user text made safe.
3234type view struct {
3335 c *Ctx
3436 w io.Writer
@@ -38,7 +40,7 @@ type view struct {
3840func (c *Ctx) view(w io.Writer) *view { return &view{c: c, w: w} }
3941
4042func (v *view) opts() termtext.Options {
41 return termtext.Options{Width: max(0, v.c.Term.Cols-2), Color: v.c.Term.Color, Base: v.c.Cfg.Server.SiteURL}
43 return termtext.Options{Base: v.c.Cfg.Server.SiteURL}
4244}
4345
4446// sep writes a blank line before the next block, unless this view has
@@ -52,82 +54,32 @@ func (v *view) sep() {
5254 v.wrote = true
5355}
5456
55// section prints a sub-table's label: a blank line, then the label
56// capitalised and bold at a terminal, or "label:" in plain. Callers skip the call entirely
57// when the table it introduces has no rows.
57// section prints a sub-table's label: a blank line, then "label:".
58// Callers skip the call entirely when the table it introduces has no
59// rows.
5860func (v *view) section(label string) {
5961 v.sep()
60 if v.c.Term.Cols == 0 {
61 io.WriteString(v.w, label+":\n")
62 return
63 }
64 io.WriteString(v.w, v.c.Term.heading(label)+"\n")
62 io.WriteString(v.w, label+":\n")
6563}
6664
67// title prints "ref title state", wrapping title+state to the
68// terminal width. Continuation lines indent under the title, and each
69// line is painted after wrapping so no SGR sequence crosses a break.
70// title or state may be "": either is skipped rather than leaving a
71// trailing blank field.
65// title prints "ref title state" on one line. title or state may be
66// "": either is skipped rather than leaving a trailing blank field.
7267func (v *view) title(ref, title, state string) {
7368 v.sep()
7469 t := v.c.Term
7570 ref, title, state = t.safe(ref), t.safe(title), t.safe(state)
76 if t.Cols == 0 {
77 switch {
78 case title == "" && state == "":
79 io.WriteString(v.w, ref+"\n")
80 case state == "":
81 io.WriteString(v.w, ref+" "+t.paint(sgrBold, title)+"\n")
82 case title == "":
83 io.WriteString(v.w, ref+" "+t.paint(stateColor(state), state)+"\n")
84 default:
85 io.WriteString(v.w, ref+" "+t.paint(sgrBold, title)+" "+t.paint(stateColor(state), state)+"\n")
86 }
87 return
71 line := ref
72 if title != "" {
73 line += " " + title
8874 }
89 prefix := ref + " "
90 indent := strings.Repeat(" ", cells(prefix))
91 rest := title
9275 if state != "" {
93 if rest != "" {
94 rest += " "
95 }
96 rest += state
97 }
98 if rest == "" {
99 io.WriteString(v.w, t.paint(sgrDim, ref)+"\n")
100 return
101 }
102 lines := termtext.Wrap(rest, t.Cols-cells(prefix))
103 for i, line := range lines {
104 p := indent
105 if i == 0 {
106 p = t.paint(sgrDim, ref) + " "
107 }
108 if i < len(lines)-1 || state == "" {
109 io.WriteString(v.w, p+t.paint(sgrBold, line)+"\n")
110 continue
111 }
112 // Last line carries the state word, the last word overall
113 // (states are single words): keep it coloured, not bold.
114 head, last := line, line
115 if idx := strings.LastIndex(line, " "); idx >= 0 {
116 head, last = line[:idx], line[idx+1:]
117 } else {
118 head = ""
119 }
120 io.WriteString(v.w, p)
121 if head != "" {
122 io.WriteString(v.w, t.paint(sgrBold, head)+" ")
123 }
124 io.WriteString(v.w, t.paint(stateColor(state), last)+"\n")
76 line += " " + state
12577 }
78 io.WriteString(v.w, line+"\n")
12679}
12780
12881// fields prints key/value pairs aligned on the widest key, skipping
129// empty values. A value that does not fit wraps, its continuation
130// lines indented to the value column.
82// empty values.
13183func (v *view) fields(kv ...string) {
13284 wide := 0
13385 for i := 0; i+1 < len(kv); i += 2 {
@@ -141,19 +93,7 @@ func (v *view) fields(kv ...string) {
14193 if val == "" {
14294 continue
14395 }
144 prefix := " " + v.c.Term.paint(sgrDim, pad(key, wide)) + " "
145 if v.c.Term.Cols == 0 {
146 io.WriteString(v.w, prefix+val+"\n")
147 continue
148 }
149 indent := strings.Repeat(" ", 2+wide+2)
150 for j, line := range termtext.Wrap(val, v.c.Term.Cols-2-wide-2) {
151 p := indent
152 if j == 0 {
153 p = prefix
154 }
155 io.WriteString(v.w, p+line+"\n")
156 }
96 io.WriteString(v.w, " "+pad(key, wide)+" "+val+"\n")
15797 }
15898}
15999
@@ -190,32 +130,18 @@ func (v *view) text(src string) {
190130}
191131
192132// event is one line for a system comment: its text without link
193// targets, the time at the right edge at a terminal.
133// targets, then the time.
194134func (v *view) event(text, format, ts string) {
195135 line := "· " + termtext.Inline(v.c.Term.safe(text), format)
196 when := v.c.Term.safe(v.c.when(ts))
197 if cols := v.c.Term.Cols; cols > 0 {
198 room := cols - 2 - 2 - cells(when)
199 line = pad(clip(line, room), room)
200 }
201 io.WriteString(v.w, " "+v.c.Term.paint(sgrDim, line+" "+when)+"\n")
136 io.WriteString(v.w, " "+line+" "+v.c.Term.safe(stamp(ts))+"\n")
202137 v.wrote = true
203138}
204139
205140func (v *view) comment(id int64, author, ts, body, format string) {
206 when := v.c.Term.safe(v.c.when(ts)) + fmt.Sprintf(" (comment %d)", id)
207 author = v.c.Term.safe(author)
208 cols := v.c.Term.Cols
209 if cols > 0 {
210 suffix := ", " + when + " "
211 author = clip(author, max(0, cols-cells("── "+suffix)-1))
212 }
213 head := "── " + author + ", " + when + " "
214 if cols > 0 {
215 head += strings.Repeat("─", max(1, cols-cells(head)))
216 }
141 when := v.c.Term.safe(stamp(ts)) + fmt.Sprintf(" (comment %d)", id)
142 head := "── " + v.c.Term.safe(author) + ", " + when + " "
217143 v.sep()
218 io.WriteString(v.w, v.c.Term.paint(sgrDim, head)+"\n")
144 io.WriteString(v.w, head+"\n")
219145 v.body(body, format)
220146}
221147
internal/control/view_test.go +20
@@ -132,3 +132,23 @@ func issueIDFor(t *testing.T, st *store.Store, repoID, n int64) int64 {
132132 }
133133 return issue.ID
134134}
135
136// view is the piped writer: at a terminal it writes what it writes
137// piped, so a caller that never migrated falls back to the plain layout.
138func TestViewIgnoresTerminal(t *testing.T) {
139 render := func(term Term) string {
140 var b bytes.Buffer
141 c := &Ctx{Term: term}
142 v := c.view(&b)
143 v.title("#1", "A title", "open")
144 v.fields("author", "alice", "when", "2026-09-01T10:00:00Z")
145 v.body("Some *body* text that is long enough to wrap at forty columns, if it wraps.", "md")
146 v.section("files")
147 v.event("labelled [bug](/x)", "md", "2026-09-01T10:00:00Z")
148 v.comment(1, "bob", "2026-09-01T10:00:00Z", "A comment.", "md")
149 return b.String()
150 }
151 if got, want := render(Term{Cols: 40, Color: true}), render(Term{}); got != want {
152 t.Errorf("terminal view differs from piped:\n%s\n---\n%s", got, want)
153 }
154}
internal/control/wiki.go +26 −19
@@ -98,12 +98,26 @@ func runWikiList(c *Ctx, args []string) int {
9898 if d.Pages == nil {
9999 d.Pages = []string{}
100100 }
101 return c.emit(d, func(w io.Writer) {
101 return c.emitView(d, func(w io.Writer) {
102102 tb := c.table(w, "PAGE")
103103 for _, p := range d.Pages {
104104 tb.row(cRef(p))
105105 }
106106 tb.flush()
107 }, func() screen {
108 rows := make([]row, len(d.Pages))
109 for i, pg := range d.Pages {
110 home := ""
111 if pg == d.Home {
112 home = "home"
113 }
114 rows[i] = rowOf(cLink(pg, c.siteURL(repo.Path(), "wiki", pg)), cMeta(home))
115 }
116 s := listScreen("Wiki", rows)
117 if d.Home != "" {
118 s.actions = []action{{"Read", []string{"wiki", "show", repo.Path(), d.Home}}}
119 }
120 return s
107121 })
108122}
109123
@@ -170,27 +184,20 @@ func runWikiShow(c *Ctx, args []string) int {
170184 if ext == ".org" {
171185 format = "org"
172186 }
173 return c.emit(d, func(w io.Writer) {
187 return c.emitView(d, func(w io.Writer) {
174188 // Plain: the page source verbatim, same as any other piped
175 // file read. The title/fields/body layout is terminal-only.
176 if c.Term.Cols == 0 {
177 fmt.Fprint(w, d.Content)
178 return
179 }
180 v := c.view(w)
181 v.title(repo.Path(), page, "")
182 binaryNote := ""
189 // file read.
190 fmt.Fprint(w, d.Content)
191 }, func() screen {
192 s := screen{fields: []field{
193 {"Page", []cell{cLink(page, c.siteURL(repo.Path(), "wiki", page)), cMeta(repo.Path(), d.File)}},
194 }, actions: []action{{"Read", []string{"wiki", "list", repo.Path()}}}}
183195 if binary {
184 binaryNote = fmt.Sprintf("%d bytes, binary", d.Size)
185 }
186 v.fields(
187 "file", d.File,
188 "binary", binaryNote,
189 "url", c.siteURL(repo.Path(), "wiki", page),
190 )
191 if !binary {
192 v.body(d.Content, format)
196 s.fields = append(s.fields, field{"Binary", []cell{cSize(int64(d.Size))}})
197 } else {
198 s.body, s.format = d.Content, format
193199 }
200 return s
194201 })
195202 }
196203 return c.fail(protocol.ExitNotFound, "no wiki page %q in %s", page, repo.Path())